浅尝WebRTC拉流,实现两个小功能
最近搞了个前端使用WebRTC拉取视频流,视频流是一个数字人的展示,然后通过文本输入/语音录入的方式向数字人提问的小功能。对实现的方案以及遇到的问题做一个小总结
什么是WebRTC拉流
WebRTC拉流是指使用WebRTC协议从服务器获取已有直播内容的过程。 WebRTC(Web Real-Time Communication)是一个支持浏览器进行实时语音、视频对话和数据传输的开源协议。在拉流过程中,主要涉及到以下步骤:
1. 获取媒体流:拉流的第一步是获取媒体流,这些媒体流可以是摄像头、麦克风或屏幕共享的内容。在WebRTC中,通常使用getUserMedia API来获取这些媒体流。
2. 创建RTCPeerConnection:RTCPeerConnection是WebRTC中用于处理与远程对等方之间音视频通信的关键对象。在拉流场景中,需要使用RTCPeerConnection来接收远程对等方发送的媒体流。
3. 接收远程流:通过RTCPeerConnection,可以接收远程对等方发送的媒体流。
4. 设置远程SDP:接收到远程SDP(会话描述协议)后,需要将其设置为远程对等方的描述,以便RTCPeerConnection知道远程对等方希望发送哪种类型的媒体流。
总的来说,WebRTC拉流允许客户端从服务器获取实时音视频数据,并在浏览器中进行解码和渲染,从而实现音视频内容的实时播放。浏览器暴露出RTCPeerConnection方法之后,手动触发一个发offer(sdp就在这个offer里面)的方法之后就可以跟服务器实时媒体流进行交互,通过setRemoteDescription从服务端接收SDP数据,两边这么一握手,诶做一个信息对等就可以进行通信了
拉流功能实现
定一个div,把具名为'jswebrtc'的元素创建成一个视频元素对象,从'data-url'属性中获取视频流地址
Player构造函数用于接收视频流的URL和选项作为参数,然后调用startLoading 方法用于开始加载视频流,创建RTC链接监听‘ontrack’事件,当收到视频轨道时将其赋值给视频元素,然后创建一个offer,发送给服务器等待answer,服务器answer后将服务器的answer设置为远程描述,通信完成,如果设置了autoplay 选项,则会自动播放视频。
play、pause、stop 和 destroy这些方法用于控制视频的播放、暂停、停止和销毁。
update 方法用于更新视频播放状态,通过递归调用自身来持续更新视频的播放状态。
<div style="z-index: 1;" class="jswebrtc" data-url="webrtc://地址"></div>
<script>
// 定义 JSWebrtc 对象
var JSWebrtc = {
Player: null, // 播放器对象
VideoElement: null, // 视频元素对象
// 创建视频元素方法
CreateVideoElements: function () {
// 获取所有具有类名 'jswebrtc' 的元素
var elements = document.querySelectorAll('.jswebrtc');
// 遍历元素数组
for (var i = 0; i < elements.length; i++) {
// 创建视频元素对象并传入当前元素
new JSWebrtc.VideoElement(elements[i]);
}
},
// 填充查询参数方法
FillQuery: function (query_string, obj) {
// 初始化用户查询对象
obj.user_query = {};
// 如果查询字符串为空,则返回
if (query_string.length == 0) return;
// 如果查询字符串中包含 '?',则将其分割为数组
if (query_string.indexOf('?') >= 0)
query_string = query_string.split('?')[1];
// 将查询字符串按 '&' 分割为数组
var queries = query_string.split('&');
// 遍历查询数组
for (var i = 0; i < queries.length; i++) {
// 将每个查询按 '=' 分割为键值对
var query = queries[i].split('=');
// 将键值对添加到目标对象和用户查询对象中
obj[query[0]] = query[1];
obj.user_query[query[0]] = query[1];
}
// 如果目标对象包含 domain 属性,则将其赋值给 vhost 属性
if (obj.domain) obj.vhost = obj.domain;
},
// 解析 URL 方法
ParseUrl: function (rtmp_url) {
// 创建一个链接元素
var a = document.createElement('a');
// 将 RTMP URL 替换为 HTTP 协议
a.href = rtmp_url
.replace('rtmp://', 'http://')
.replace('webrtc://', 'http://')
.replace('rtc://', 'http://');
// 获取主机名、应用和流名称等信息
var vhost = a.hostname;
var app = a.pathname.substr(1, a.pathname.lastIndexOf('/') - 1);
var stream = a.pathname.substr(a.pathname.lastIndexOf('/') + 1);
// 替换应用中的特殊字符
app = app.replace('...vhost...', '?vhost=');
// 如果应用中包含查询参数,则解析出 vhost 参数
if (app.indexOf('?') >= 0) {
var params = app.substr(app.indexOf('?'));
app = app.substr(0, app.indexOf('?'));
if (params.indexOf('vhost=') > 0) {
vhost = params.substr(params.indexOf('vhost=') + 'vhost='.length);
if (vhost.indexOf('&') > 0) {
vhost = vhost.substr(0, vhost.indexOf('&'));
}
}
}
// 如果主机名为 IP 地址,则将 vhost 设置为默认值
if (a.hostname == vhost) {
var re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
if (re.test(a.hostname)) vhost = '__defaultVhost__';
}
// 设置协议、端口和返回对象
var schema = 'rtmp';
if (rtmp_url.indexOf('://') > 0)
schema = rtmp_url.substr(0, rtmp_url.indexOf('://'));
var port = a.port;
if (!port) {
if (schema === 'http') {
port = 80;
} else if (schema === 'https') {
port = 443;
} else if (schema === 'rtmp') {
port = 1935;
} else if (schema === 'webrtc' || schema === 'rtc') {
port = 1985;
}
}
// 构建返回对象
var ret = {
url: rtmp_url,
schema: schema,
server: a.hostname,
port: port,
vhost: vhost,
app: app,
stream: stream,
};
// 填充查询参数到返回对象中
JSWebrtc.FillQuery(a.search, ret);
return ret;
},
// 发起 HTTP POST 请求方法
HttpPost: function (url, data) {
// 返回一个 Promise 对象
return new Promise(function (resolve, reject) {
// 创建 XMLHttpRequest 对象
var xhr = new XMLHttpRequest();
// 监听状态改变事件
xhr.onreadystatechange = function () {
// 请求完成且状态码为成功时
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
// 解析响应数据为 JSON 格式
var respone = JSON.parse(xhr.responseText);
// 清除监听事件并置空 XMLHttpRequest 对象
xhr.onreadystatechange = new Function();
xhr = null;
// 解析响应数据并返回
resolve(respone);
}
};
// 打开 POST 请求,并设置超时时间、响应类型和请求头
xhr.open('POST', url, true);
xhr.timeout = 5e3;
xhr.responseType = 'text';
xhr.setRequestHeader('Content-Type', 'application/json');
// 发送数据
xhr.send(data);
});
},
};
// 当文档加载完成时
if (document.readyState === 'complete') {
// 创建视频元素
JSWebrtc.CreateVideoElements();
} else {
// 监听 DOMContentLoaded 事件,并创建视频元素
document.addEventListener('DOMContentLoaded', JSWebrtc.CreateVideoElements);
}
// 视频元素对象
JSWebrtc.VideoElement = (function () {
'use strict';
// 视频元素构造函数
var VideoElement = function (element) {
// 获取 data-url 属性值
var url = element.dataset.url;
// 如果没有设置 data-url 属性,则抛出异常
if (!url) {
throw 'VideoElement has no `data-url` attribute';
}
// 设置容器元素和视频元素
this.container = element;
this.video = document.createElement('video');
this.video.width = '576';
this.video.height = '864';
this.video.autoplay = true; // 自动播放
this.video.controls = true; // 显示视频自带的控制按钮
this.container.appendChild(this.video);
// 解析 data-* 属性为选项,并创建播放器对象
var options = { video: this.video };
for (var option in element.dataset) {
try {
options[option] = JSON.parse(element.dataset[option]);
} catch (err) {
options[option] = element.dataset[option];
}
}
this.player = new JSWebrtc.Player(url, options);
element.playerInstance = this.player;
};
return VideoElement;
})();
// 播放器对象
JSWebrtc.Player = (function () {
'use strict';
// 播放器构造函数
var Player = function (url, options) {
this.options = options || {};
// 如果 URL 不是以 webrtc:// 开头,则抛出异常
if (!url.match(/^webrtc?:\/\//)) {
throw 'JSWebrtc just work with webrtc';
}
// 如果没有设置视频元素,则抛出异常
if (!this.options.video) {
throw 'VideoElement is null';
}
// 解析 URL 参数
this.urlParams = JSWebrtc.ParseUrl(url);
this.pc = null;
this.autoplay = !!options.autoplay || true;
this.paused = true;
if (this.autoplay) this.options.video.muted = true;
this.startLoading();
};
// 开始加载方法
Player.prototype.startLoading = function () {
var _self = this;
// 关闭当前 PeerConnection
if (_self.pc) {
_self.pc.close();
}
// 创建新的 PeerConnection
_self.pc = new RTCPeerConnection(null);
_self.pc.ontrack = function (event) {
_self.options.video['srcObject'] = event.streams[0];
};
_self.pc.addTransceiver('audio', { direction: 'recvonly' });
_self.pc.addTransceiver('video', { direction: 'recvonly' });
_self.pc
.createOffer()
.then(function (offer) {
return _self.pc.setLocalDescription(offer).then(function () {
return offer;
});
})
.then(function (offer) {
return new Promise(function (resolve, reject) {
var port = _self.urlParams.port || 1985;
var api = _self.urlParams.user_query.play || '/rtc/v1/play/';
if (api.lastIndexOf('/') != api.length - 1) {
api += '/';
}
var url = 'http://' + _self.urlParams.server + ':' + port + api;
for (var key in _self.urlParams.user_query) {
if (key != 'api' && key != 'play') {
url += '&' + key + '=' + _self.urlParams.user_query[key];
}
}
var data = {
api: url,
streamurl: _self.urlParams.url,
clientip: null,
sdp: offer.sdp,
};
// 发送 HTTP POST 请求
JSWebrtc.HttpPost(url, JSON.stringify(data)).then(
function (res) {
resolve(res.sdp);
},
function (rej) {
reject(rej);
}
);
});
})
.then(function (answer) {
return _self.pc.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: answer })
);
})
.catch(function (reason) {
throw reason;
});
if (this.autoplay) {
this.play();
}
};
// 播放方法
Player.prototype.play = function (ev) {
if (this.animationId) {
return;
}
this.animationId = requestAnimationFrame(this.update.bind(this));
this.paused = false;
};
// 暂停方法
Player.prototype.pause = function (ev) {
if (this.paused) {
return;
}
cancelAnimationFrame(this.animationId);
this.animationId = null;
this.isPlaying = false;
this.paused = true;
this.options.video.pause();
if (this.options.onPause) {
this.options.onPause(this);
}
};
// 停止方法
Player.prototype.stop = function (ev) {
this.pause();
};
// 销毁方法
Player.prototype.destroy = function () {
this.pause();
this.pc && this.pc.close() && this.pc.destroy();
this.audioOut && this.audioOut.destroy();
};
// 更新方法
Player.prototype.update = function () {
this.animationId = requestAnimationFrame(this.update.bind(this));
if (this.options.video.readyState < 4) {
return;
}
if (!this.isPlaying) {
this.isPlaying = true;
this.options.video.play();
if (this.options.onPlay) {
this.options.onPlay(this);
}
}
};
return Player;
})();
</script>
- 通过上面一系列的操作之后就可以实现把视频流渲染到播放器里了
实现效果
录音转文本功能
录音转文本功能的实现是通过接入讯飞录音转文本的sdk接口地址 讯飞语音听写WebAPI文档
下载里面的js demo即可
定义好自己的'APPID'、'API_SECRET'、'API_KEY', 然后在index.js文件做一点简单的修改,把生成的文本传到后端接口里即可:
function renderResult(resultData) {
// 识别结束
let jsonData = JSON.parse(resultData);
if (jsonData.data && jsonData.data.result) {
let data = jsonData.data.result;
let str = "";
let ws = data.ws;
for (let i = 0; i < ws.length; i++) {
str = str + ws[i].cw[0].w;
}
// 开启wpgs会有此字段(前提:在控制台开通动态修正功能)
// 取值为 "apd"时表示该片结果是追加到前面的最终结果;取值为"rpl" 时表示替换前面的部分结果,替换范围为rg字段
if (data.pgs) {
if (data.pgs === "apd") {
// 将resultTextTemp同步给resultText
resultText = resultTextTemp;
}
// 将结果存储在resultTextTemp中
resultTextTemp = resultText + str;
} else {
resultText = resultText + str;
}
if(btnStatus === 'CLOSING') {
...
// 在这里调用你的接口把resultTextTemp/resultText参数传过去
}
}
if (jsonData.code === 0 && jsonData.data.status === 2) {
iatWS.close();
}
if (jsonData.code !== 0) {
iatWS.close();
console.error(jsonData);
}
}
- 到这里就实现了录音转文本之后调用接口传文本信息,成功后视频流里的数字人会做出响应。
以上