本研究的主要目的是基于Python aiortc api實現抓取本地設備媒體流(攝像機、麥克風)并與Web端實現P2P通話。本文章僅僅描述實現思路,索要源碼請私信我。
1 demo-server解耦
1.1 原始代碼解析
1.1.1 http服務器端
import argparse
import asyncio
import json
import logging
import os
import ssl
import uuidimport cv2
from aiohttp import web
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder, MediaRelay
from av import VideoFrame
# 這行代碼設置了 ROOT 變量,它代表了當前執行文件(腳本)所在的目錄。
# __file__ 是 Python 中的一個特殊變量,它包含了當前文件的路徑。
# os.path.dirname 函數返回路徑中的目錄名稱。
# 這個變量通常用于構建其他文件路徑,確保它們相對于當前腳本的位置。
ROOT = os.path.dirname(__file__)
# 這行代碼創建了一個日志記錄器(logger),它的名稱是 "pc"。在 Python 的 logging 模塊中,
# 每個 logger 都有一個唯一的名稱,你可以使用這個名稱來獲取對應的 logger 實例。
# 這個 logger 將用于記錄程序中與 WebRTC 相關的事件和信息。
logger = logging.getLogger("pc")
# 這行代碼初始化了一個空的集合(set),名為 pcs。
# 這個集合可能用于存儲 RTCPeerConnection 對象的引用。
# 使用集合可以方便地進行添加、檢查和刪除操作,并且集合中的元素是唯一的。
pcs = set()
# 這行代碼創建了一個 MediaRelay 對象,名為 relay。MediaRelay 可能是一個用于
# 中繼媒體流的自定義類,它允許將從一個 RTCPeerConnection
# 接收到的媒體流轉發到另一個 RTCPeerConnection。
# 這種類型的中繼通常用于 MCU(Multipoint Control Unit)場景或簡單的媒體路由。
relay = MediaRelay()# 這段代碼定義了一個名為 VideoTransformTrack 的類,它是 MediaStreamTrack 的子類。
# VideoTransformTrack 類的目的是從一個已有的視頻軌道(track)接收幀,
# 并對這些幀應用特定的轉換(transform),
# 然后返回轉換后的幀。以下是對類中各個部分的詳細解釋:
class VideoTransformTrack(MediaStreamTrack): # 繼承"""A video stream track that transforms frames from an another track."""
# kind = "video": 這行代碼設置了軌道的類型為視頻。kind = "video"
# 構造函數 __init__(self, track, transform)
# track 參數是另一個 MediaStreamTrack 實例,VideoTransformTrack 將從這個軌道接收幀。
# transform 參數是一個字符串,指定要應用的轉換類型,可以是 "cartoon"、"edges" 或 "rotate"。def __init__(self, track, transform):super().__init__() # don't forget this!self.track = trackself.transform = transform# 這個方法展示了如何使用OpenCV對視頻幀進行實時處理,包括卡通效果、邊緣檢測和旋轉,
# 并將處理后的幀返回給WebRTC軌道。
# 這段代碼是VideoTransformTrack類中的recv方法,它是一個異步方法,# 用于接收視頻幀并根據指定的轉換類型對幀進行處理。# 下面是對這個方法的詳細解釋:async def recv(self):# 這行代碼異步地從self.track(即類的track屬性,一個視頻軌道)接收一個視頻幀。frame = await self.track.recv()
# 如果轉換類型是“cartoon”,則將接收到的視頻幀轉換為BGR顏色空間的NumPy數組。if self.transform == "cartoon":img = frame.to_ndarray(format="bgr24")
# 這部分代碼首先對圖像進行兩次降采樣(pyrDown),然后應用六次雙邊濾波(bilateralFilter),# 最后進行兩次升采樣(pyrUp)。# 這個過程有助于減少圖像噪聲并保持邊緣清晰。# prepare colorimg_color = cv2.pyrDown(cv2.pyrDown(img))for _ in range(6):img_color = cv2.bilateralFilter(img_color, 9, 9, 7)img_color = cv2.pyrUp(cv2.pyrUp(img_color))
# 這部分代碼將圖像轉換為灰度圖,然后應用中值濾波(medianBlur),# 自適應閾值(adaptiveThreshold)來提取邊緣,
# 最后將灰度圖轉換回RGB顏色空間。# prepare edgesimg_edges = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)img_edges = cv2.adaptiveThreshold(cv2.medianBlur(img_edges, 7),255,cv2.ADAPTIVE_THRESH_MEAN_C,cv2.THRESH_BINARY,9,2,)# 將處理過的顏色和邊緣圖像進行按位與操作,以結合兩者。img_edges = cv2.cvtColor(img_edges, cv2.COLOR_GRAY2RGB)# combine color and edgesimg = cv2.bitwise_and(img_color, img_edges)
# 最后,將處理后的NumPy數組轉換回VideoFrame對象,
# 并保留原始幀的時間戳(pts)和時間基(time_base),然后返回這個新幀。# rebuild a VideoFrame, preserving timing informationnew_frame = VideoFrame.from_ndarray(img, format="bgr24")new_frame.pts = frame.ptsnew_frame.time_base = frame.time_basereturn new_frame# 如果轉換類型是“edges”,則對幀進行Canny邊緣檢測,并將結果轉換回BGR顏色空間。elif self.transform == "edges":# perform edge detectionimg = frame.to_ndarray(format="bgr24")img = cv2.cvtColor(cv2.Canny(img, 100, 200), cv2.COLOR_GRAY2BGR)
# 然后將處理后的邊緣檢測圖像轉換回VideoFrame對象,并返回。# rebuild a VideoFrame, preserving timing informationnew_frame = VideoFrame.from_ndarray(img, format="bgr24")new_frame.pts = frame.ptsnew_frame.time_base = frame.time_basereturn new_frame# 如果轉換類型是“rotate”,則將幀轉換為NumPy數組,并計算旋轉矩陣,# 然后應用仿射變換(warpAffine)來旋轉圖像。elif self.transform == "rotate":# rotate imageimg = frame.to_ndarray(format="bgr24")rows, cols, _ = img.shapeM = cv2.getRotationMatrix2D((cols / 2, rows / 2), frame.time * 45, 1)img = cv2.warpAffine(img, M, (cols, rows))
# 將旋轉后的圖像轉換回VideoFrame對象,并返回。# rebuild a VideoFrame, preserving timing informationnew_frame = VideoFrame.from_ndarray(img, format="bgr24")new_frame.pts = frame.ptsnew_frame.time_base = frame.time_basereturn new_frameelse:# 如果轉換類型既不是“cartoon”、“edges”也不是“rotate”,則直接返回原始幀。return frame# 這個函數處理對服務器根URL(通常是/)的GET請求。
# 它的作用是返回服務器根目錄下index.html文件的內容作為HTTP響應。
# request:這是aiohttp傳入的請求對象,包含了請求的詳細信息。
# os.path.join(ROOT, "index.html"):使用os.path.join函數構建index.html文件的完整路徑。
# ROOT是之前定義的服務器根目錄變量。
# open(...).read():以只讀模式打開index.html文件,并讀取其內容。
# web.Response(content_type="text/html", text=content):創建一個aiohttp響應對象,
# 設置內容類型為text/html,并將讀取的HTML內容作為響應正文返回。
async def index(request):content = open(os.path.join(ROOT, "index.html"), "r").read()return web.Response(content_type="text/html", text=content)# 這個函數處理對/client.js路徑的GET請求。它的作用是返回服務器根目錄下
# client.js文件的內容作為HTTP響應。
# request:這是aiohttp傳入的請求對象。
# os.path.join(ROOT, "client.js"):構建client.js文件的完整路徑。
# open(...).read():以只讀模式打開client.js文件,并讀取其內容。
# web.Response(content_type="application/javascript", text=content):
# 創建一個aiohttp響應對象,設置內容類型為application/javascript,
# 并將讀取的JavaScript內容作為響應正文返回。
async def javascript(request):content = open(os.path.join(ROOT, "client.js"), "r").read()return web.Response(content_type="application/javascript", text=content)# 這個offer函數是一個異步的Web服務器路由處理函數,用于處理WebRTC連接的建立過程。
# 它接收客戶端發送的offer,
# 創建或處理一個RTCPeerConnection對象,并返回一個answer給客戶端。
async def offer(request):# 這行代碼異步地解析客戶端請求的JSON數據,通常包含SDP(會話描述協議)信息和其他參數。params = await request.json()# 使用客戶端發送的SDP信息和類型創建一個RTCSessionDescription對象,這個對象表示客戶端的offer。offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])# 創建一個RTCPeerConnection對象,這是WebRTC中用于管理WebRTC連接的對象。pc = RTCPeerConnection()# 為這個連接創建一個唯一的ID。pc_id = "PeerConnection(%s)" % uuid.uuid4()# 將這個連接對象添加到全局的連接集合中,以便后續可以對其進行管理。pcs.add(pc)# 定義一個日志記錄函數,用于記錄與特定連接相關的信息。def log_info(msg, *args):logger.info(pc_id + " " + msg, *args)# 記錄創建連接的遠程地址信息。log_info("Created for %s", request.remote)# 準備本地媒體# prepare local media# 創建一個MediaPlayer對象,用于播放本地音頻文件。player = MediaPlayer(os.path.join(ROOT, "demo-instruct.wav"))# 根據命令行參數決定是否創建一個MediaRecorder對象來錄制接收到的媒體流,# 或者使用一個MediaBlackhole對象來忽略接收到的媒體流。if args.record_to:recorder = MediaRecorder(args.record_to)else:recorder = MediaBlackhole()# 監聽數據通道事件。@pc.on("datachannel")def on_datachannel(channel):# 在數據通道上監聽消息事件。@channel.on("message")def on_message(message):# 如果接收到的消息是字符串并且以"ping"開頭,則回復一個以"pong"開頭的消息。if isinstance(message, str) and message.startswith("ping"):channel.send("pong" + message[4:])# 監聽連接狀態變化事件。@pc.on("connectionstatechange")async def on_connectionstatechange():log_info("Connection state is %s", pc.connectionState)# 記錄連接狀態。# 如果連接狀態為失敗,則關閉連接并從集合中移除。if pc.connectionState == "failed":await pc.close()pcs.discard(pc)@pc.on("track")def on_track(track):log_info("Track %s received", track.kind)# 監聽軌道事件。# 如果接收到的是音頻軌道,則將播放器的音頻軌道添加到連接中,并錄制接收到的音頻軌道。if track.kind == "audio":pc.addTrack(player.audio)recorder.addTrack(track)# 如果接收到的是視頻軌道,則創建一個VideoTransformTrack對象來處理視頻,并將其添加到連接中。elif track.kind == "video":pc.addTrack(VideoTransformTrack(relay.subscribe(track), transform=params["video_transform"]))# 如果需要錄制,則將接收到的視頻軌道添加到錄制器中。if args.record_to:recorder.addTrack(relay.subscribe(track))# 監聽軌道結束事件。@track.on("ended")async def on_ended():log_info("Track %s ended", track.kind)# 當軌道結束時,停止錄制。await recorder.stop()# 處理offer和發送answer# handle offer將客戶端的offer設置為遠程描述。await pc.setRemoteDescription(offer)# 開始錄制。await recorder.start()# 創建answer。# send answeranswer = await pc.createAnswer()# 將創建的answer設置為本地描述。await pc.setLocalDescription(answer)# 將本地描述的SDP信息和類型以JSON格式返回給客戶端。# 這個函數展示了如何使用aiortc庫來處理WebRTC的offer/answer模型,# 包括創建連接、處理媒體流、設置事件監聽器以及發送answer。return web.Response(content_type="application/json",text=json.dumps({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}),)# 這個on_shutdown函數是一個異步函數,它被設計為在Web服務器關閉時執行。
# 它的主要任務是優雅地關閉所有的RTCPeerConnection對象,并清理相關的資源。
# 這個函數接受一個參數app,它代表aiohttp的應用程序實例。這個參數在這個函數中并沒有被直接使用,
# 但它是aiohttp應用程序關閉事件的一部分。
async def on_shutdown(app):# close peer connections# 這行代碼創建了一個列表coros,其中包含了所有pcs集合中的RTCPeerConnection對象的關閉操作。# pc.close()是一個異步方法,用于關閉一個RTCPeerConnection對象。coros = [pc.close() for pc in pcs]# 這行代碼使用asyncio.gather來并發執行所有的關閉操作。asyncio.gather是一個異步函數,# 它接受一個可迭代的異步任務列表,并返回一個代表所有任務完成的異步任務。# 這確保了所有的RTCPeerConnection對象可以同時關閉,而不是一個接一個地關閉,# 從而提高了關閉過程的效率。await asyncio.gather(*coros)# 在所有的RTCPeerConnection對象都被關閉之后,這行代碼清空了pcs集合,移除了所有的連接對象引用。# 這是一個清理步驟,確保了在服務器關閉時不會有任何遺留的資源占用。pcs.clear()# 這段代碼是Python腳本的入口點,它負責解析命令行參數、設置日志記錄、配置SSL上下文、
# 初始化aiohttp應用,并啟動Web服務器。
if __name__ == "__main__":# 創建一個ArgumentParser對象,用于解析命令行參數。描述信息說明了這個腳本# 是一個WebRTC的音頻/視頻/數據通道演示。parser = argparse.ArgumentParser(description="WebRTC audio / video / data-channels demo")# 添加兩個命令行參數,分別用于指定SSL證書文件和密鑰文件的路徑。# 這些參數是可選的,用于配置HTTPS服務。parser.add_argument("--cert-file", help="SSL certificate file (for HTTPS)")parser.add_argument("--key-file", help="SSL key file (for HTTPS)")# 添加兩個命令行參數,用于指定HTTP服務器的主機地址和端口號。# 默認值分別是0.0.0.0(所有可用網絡接口)和8080。parser.add_argument("--host", default="0.0.0.0", help="Host for HTTP server (default: 0.0.0.0)")parser.add_argument("--port", type=int, default=8080, help="Port for HTTP server (default: 8080)")# 添加一個命令行參數,用于指定將接收到的媒體流錄制到文件的路徑。parser.add_argument("--record-to", help="Write received media to a file.")# 添加一個命令行參數,用于控制日志記錄的詳細程度。-v或--verbose可以被指定多次,以增加日志的詳細程度。parser.add_argument("--verbose", "-v", action="count")# 解析命令行參數,并將解析結果存儲在args對象中。args = parser.parse_args()# 根據args.verbose的值設置日志記錄的級別。# 如果args.verbose為真,則設置日志級別為DEBUG,否則為INFO。if args.verbose:logging.basicConfig(level=logging.DEBUG)else:logging.basicConfig(level=logging.INFO)# SSL上下文配置# 如果用戶提供了證書文件和密鑰文件,則創建一個SSL上下文對象,并加載證書和密鑰。# 否則,ssl_context被設置為None,表示不使用SSL。if args.cert_file:ssl_context = ssl.SSLContext()ssl_context.load_cert_chain(args.cert_file, args.key_file)else:ssl_context = None# 初始化aiohttp應用# 創建一個aiohttp應用實例。app = web.Application()# 將on_shutdown函數添加到應用的關閉事件中,以便在應用關閉時執行資源清理。app.on_shutdown.append(on_shutdown)# 為應用添加路由,分別處理根URL的GET請求(返回首頁)、/client.js的# GET請求(返回客戶端JavaScript代碼)和/offer的POST請求(處理WebRTC offer)app.router.add_get("/", index)app.router.add_get("/client.js", javascript)app.router.add_post("/offer", offer)# 啟動Web服務器# 啟動aiohttp應用,設置訪問日志為None(不記錄訪問日志),主機地址和端口號根據命令行參數設置,# 如果配置了SSL,則使用相應的SSL上下文。web.run_app(app, access_log=None, host=args.host, port=args.port, ssl_context=ssl_context)
1.1.2 web端
1.1.2.1 client.js
// 獲取DOM元素
var dataChannelLog = document.getElementById('data-channel'), // 獲取數據通道日志元素iceConnectionLog = document.getElementById('ice-connection-state'), // 獲取ICE連接狀態元素iceGatheringLog = document.getElementById('ice-gathering-state'), // 獲取ICE收集狀態元素signalingLog = document.getElementById('signaling-state'); // 獲取信令狀態元素// 對等連接對象
var pc = null; // 初始化對等連接對象為null// 數據通道對象
var dc = null, dcInterval = null; // 初始化數據通道對象和定時器// 創建對等連接
function createPeerConnection() {var config = {sdpSemantics: 'unified-plan' // 設置SDP語義};// 如果選中了使用STUN服務器if (document.getElementById('use-stun').checked) {config.iceServers = [{ urls: ['stun:stun.l.google.com:19302'] }]; // 配置STUN服務器}// 創建新的對等連接實例pc = new RTCPeerConnection(config);// 注冊一些監聽器以幫助調試pc.addEventListener('icegatheringstatechange', () => { // 當ICE收集狀態改變時iceGatheringLog.textContent += ' -> ' + pc.iceGatheringState; // 更新ICE收集狀態日志}, false);iceGatheringLog.textContent = pc.iceGatheringState; // 初始化ICE收集狀態日志pc.addEventListener('iceconnectionstatechange', () => { // 當ICE連接狀態改變時iceConnectionLog.textContent += ' -> ' + pc.iceConnectionState; // 更新ICE連接狀態日志}, false);iceConnectionLog.textContent = pc.iceConnectionState; // 初始化ICE連接狀態日志pc.addEventListener('signalingstatechange', () => { // 當信令狀態改變時signalingLog.textContent += ' -> ' + pc.signalingState; // 更新信令狀態日志}, false);signalingLog.textContent = pc.signalingState; // 初始化信令狀態日志// 連接音頻/視頻pc.addEventListener('track', (evt) => { // 當接收到軌道時if (evt.track.kind == 'video') // 如果是視頻軌道document.getElementById('video').srcObject = evt.streams[0]; // 設置視頻源else // 如果是音頻軌道document.getElementById('audio').srcObject = evt.streams[0]; // 設置音頻源});return pc; // 返回對等連接實例
}// 枚舉輸入設備
function enumerateInputDevices() {const populateSelect = (select, devices) => { // 填充選擇器的函數let counter = 1;devices.forEach((device) => { // 遍歷設備const option = document.createElement('option'); // 創建新的選項option.value = device.deviceId; // 設置選項的值option.text = device.label || ('Device #' + counter); // 設置選項的文本select.appendChild(option); // 將選項添加到選擇器counter += 1;});};navigator.mediaDevices.enumerateDevices().then((devices) => { // 枚舉設備populateSelect( // 填充音頻輸入選擇器document.getElementById('audio-input'),devices.filter((device) => device.kind == 'audioinput') // 過濾音頻輸入設備);populateSelect( // 填充視頻輸入選擇器document.getElementById('video-input'),devices.filter((device) => device.kind == 'videoinput') // 過濾視頻輸入設備);}).catch((e) => { // 如果發生錯誤alert(e); // 顯示錯誤消息});
}// 協商過程
function negotiate() {return pc.createOffer().then((offer) => { // 創建offer// [add]local offer中不存在candidate信息,也就是創建pc.setLocalDescription(offer)會自動進行candidate收集。// [add]這里異步方法等待candidate收集完畢后在發送offer。console.log('local offer: ', offer);return pc.setLocalDescription(offer); // 設置本地描述}).then(() => {// 等待ICE收集完成return new Promise((resolve) => {if (pc.iceGatheringState === 'complete') {resolve(); // 如果ICE收集已完成,解析Promise} else {function checkState() { // 檢查ICE收集狀態的函數if (pc.iceGatheringState === 'complete') {pc.removeEventListener('icegatheringstatechange', checkState); // 移除事件監聽器resolve(); // 解析Promise}}pc.addEventListener('icegatheringstatechange', checkState); // 添加事件監聽器}});}).then(() => {var offer = pc.localDescription; // 獲取本地描述// [add]添加offer日志,這里的offer包含candidate信息,而在我的webrtc1v1demo代碼中,offer中沒有candidate信息。// [add]webrtc1v1demo中candidate信息是offer后進行交換的。console.log('send local offer: ', offer);var codec;codec = document.getElementById('audio-codec').value; // 獲取音頻編解碼器if (codec !== 'default') { // 如果不是默認編解碼器offer.sdp = sdpFilterCodec('audio', codec, offer.sdp); // 過濾SDP中的音頻編解碼器}codec = document.getElementById('video-codec').value; // 獲取視頻編解碼器if (codec !== 'default') { // 如果不是默認編解碼器offer.sdp = sdpFilterCodec('video', codec, offer.sdp); // 過濾SDP中的視頻編解碼器}document.getElementById('offer-sdp').textContent = offer.sdp; // 顯示offer的SDPreturn fetch('/offer', { // 發送請求到服務器body: JSON.stringify({sdp: offer.sdp,type: offer.type,video_transform: document.getElementById('video-transform').value}),headers: {'Content-Type': 'application/json'},method: 'POST'});}).then((response) => {return response.json(); // 解析響應為JSON}).then((answer) => {document.getElementById('answer-sdp').textContent = answer.sdp; // 顯示answer的SDPreturn pc.setRemoteDescription(answer); // 設置遠程描述}).catch((e) => { // 如果發生錯誤alert(e); // 顯示錯誤消息});
}// 開始過程
function start() {document.getElementById('start').style.display = 'none'; // 隱藏開始按鈕pc = createPeerConnection(); // 創建對等連接var time_start = null; // 初始化時間戳const current_stamp = () => { // 獲取當前時間戳的函數if (time_start === null) { // 如果還沒有開始計時time_start = new Date().getTime(); // 開始計時return 0; // 返回0} else {return new Date().getTime() - time_start; // 返回當前時間戳}};if (document.getElementById('use-datachannel').checked) { // 如果選中了使用數據通道var parameters = JSON.parse(document.getElementById('datachannel-parameters').value); // 獲取數據通道參數dc = pc.createDataChannel('chat', parameters); // 創建數據通道dc.addEventListener('close', () => { // 當數據通道關閉時clearInterval(dcInterval); // 清除定時器dataChannelLog.textContent += '- close\n'; // 更新數據通道日志});dc.addEventListener('open', () => { // 當數據通道打開時dataChannelLog.textContent += '- open\n'; // 更新數據通道日志dcInterval = setInterval(() => { // 設置定時器var message = 'ping ' + current_stamp(); // 創建ping消息dataChannelLog.textContent += '> ' + message + '\n'; // 更新數據通道日志dc.send(message); // 發送消息}, 1000); // 每秒發送一次});dc.addEventListener('message', (evt) => { // 當接收到消息時dataChannelLog.textContent += '< ' + evt.data + '\n'; // 更新數據通道日志if (evt.data.substring(0, 4) === 'pong') { // 如果是pong消息var elapsed_ms = current_stamp() - parseInt(evt.data.substring(5), 10); // 計算往返時間dataChannelLog.textContent += ' RTT ' + elapsed_ms + ' ms\n'; // 更新數據通道日志}});}// 構建媒體約束const constraints = {audio: false,video: false};if (document.getElementById('use-audio').checked) { // 如果選中了使用音頻const audioConstraints = {};const device = document.getElementById('audio-input').value; // 獲取音頻輸入設備if (device) { // 如果選擇了設備audioConstraints.deviceId = { exact: device }; // 設置設備ID}constraints.audio = Object.keys(audioConstraints).length ? audioConstraints : true; // 設置音頻約束}if (document.getElementById('use-video').checked) { // 如果選中了使用視頻const videoConstraints = {};const device = document.getElementById('video-input').value; // 獲取視頻輸入設備if (device) { // 如果選擇了設備videoConstraints.deviceId = { exact: device }; // 設置設備ID}const resolution = document.getElementById('video-resolution').value; // 獲取視頻分辨率if (resolution) { // 如果設置了分辨率const dimensions = resolution.split('x'); // 分割分辨率videoConstraints.width = parseInt(dimensions[0], 0); // 設置寬度videoConstraints.height = parseInt(dimensions[1], 0); // 設置高度}constraints.video = Object.keys(videoConstraints).length ? videoConstraints : true; // 設置視頻約束}// 獲取媒體并開始協商if (constraints.audio || constraints.video) { // 如果需要獲取媒體if (constraints.video) { // 如果需要視頻document.getElementById('media').style.display = 'block'; // 顯示媒體元素}navigator.mediaDevices.getUserMedia(constraints).then((stream) => { // 獲取媒體stream.getTracks().forEach((track) => { // 遍歷軌道pc.addTrack(track, stream); // 添加軌道到對等連接});return negotiate(); // 開始協商}, (err) => { // 如果發生錯誤alert('Could not acquire media: ' + err); // 顯示錯誤消息});} else { // 如果不需要獲取媒體negotiate(); // 開始協商}document.getElementById('stop').style.display = 'inline-block'; // 顯示停止按鈕
}// 停止過程
function stop() {document.getElementById('stop').style.display = 'none'; // 隱藏停止按鈕// 關閉數據通道if (dc) { // 如果存在數據通道dc.close(); // 關閉數據通道}// 關閉傳輸器if (pc.getTransceivers) { // 如果對等連接支持獲取傳輸器pc.getTransceivers().forEach((transceiver) => { // 遍歷傳輸器if (transceiver.stop) { // 如果傳輸器可以停止transceiver.stop(); // 停止傳輸器}});}// 關閉本地音頻/視頻pc.getSenders().forEach((sender) => { // 遍歷發送器sender.track.stop(); // 停止軌道});// 關閉對等連接setTimeout(() => { // 設置延遲pc.close(); // 關閉對等連接}, 500); // 500毫秒后關閉
}// 過濾SDP中的編解碼器
function sdpFilterCodec(kind, codec, realSdp) {var allowed = []var rtxRegex = new RegExp('a=fmtp:(\\d+) apt=(\\d+)\r$');var codecRegex = new RegExp('a=rtpmap:([0-9]+) ' + escapeRegExp(codec))var videoRegex = new RegExp('(m=' + kind + ' .*?)( ([0-9]+))*\\s*$')var lines = realSdp.split('\n');var isKind = false;for (var i = 0; i < lines.length; i++) {if (lines[i].startsWith('m=' + kind + ' ')) {isKind = true;} else if (lines[i].startsWith('m=')) {isKind = false;}if (isKind) {var match = lines[i].match(codecRegex);if (match) {allowed.push(parseInt(match[1]));}match = lines[i].match(rtxRegex);if (match && allowed.includes(parseInt(match[2]))) {allowed.push(parseInt(match[1]));}}}var skipRegex = 'a=(fmtp|rtcp-fb|rtpmap):([0-9]+)';var sdp = '';isKind = false;for (var i = 0; i < lines.length; i++) {if (lines[i].startsWith('m=' + kind + ' ')) {isKind = true;} else if (lines[i].startsWith('m=')) {isKind = false;}if (isKind) {var skipMatch = lines[i].match(skipRegex);if (skipMatch && !allowed.includes(parseInt(skipMatch[2]))) {continue;} else if (lines[i].match(videoRegex)) {sdp += lines[i].replace(videoRegex, '$1 ' + allowed.join(' ')) + '\n';} else {sdp += lines[i] + '\n';}} else {sdp += lines[i] + '\n';}}return sdp;
}// 轉義正則表達式字符串
function escapeRegExp(string) {return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}enumerateInputDevices(); // 枚舉輸入設備
1.1.3 解耦
1.1.3.1 設備端部署音頻服務
修改說明
- 取消Web服務:
- 刪除了
index
和javascript
函數。 - 刪除了與Web服務相關的路由設置。
- 刪除了
- 修改
**offer**
方法:- 將
offer
方法改為connect_to_websocket
,用于連接到WebSocket服務并接收消息。
- 將
- 解耦Web服務到WebSocket服務:
- 將Web服務的功能解耦到WebSocket服務中,WebRTC的Web端由WebSocket服務提供(Java實現)。
- WebSocket客戶端:
- 使用
websockets
庫連接到WebSocket服務。 - 接收WebSocket服務發送的
offer
消息,并處理offer
消息。 - 創建
RTCPeerConnection
對象,并設置遠程描述。 - 創建本地描述,并將其發送回WebSocket服務。
- 使用
1.1.3.2 Web服務增加信令服務(Java實現)
1.1.3.3 client.js修改為ws調用
修改說明
- 添加 WebSocket 連接:
- 在
connectToWebSocket()
函數中創建 WebSocket 連接,并處理onopen
、onmessage
和onclose
事件。
- 在
- 修改
**start()**
函數:- 在
start()
函數中,通過 WebSocket 連接發送消息,而不是使用fetch
發送 HTTP 請求。
- 在
- 處理 WebSocket 消息:
- 在
connectToWebSocket()
函數中,處理從 WebSocket 服務接收到的消息,并調用handleOffer()
和handleAnswer()
函數。
- 在
- 頁面加載時連接 WebSocket:
- 在
window.onload
事件中調用connectToWebSocket()
函數,確保頁面加載時自動連接 WebSocket 服務。
- 在
2 抓取本地麥克風流
3 音頻效果