# 政务远程帮办:WebRTC视频通话+录屏录音+手工拼WAV实录

政务远程帮办:WebRTC视频通话+录屏录音+手工拼WAV,从0到1实录

非科班野生程序员,深耕政务信息化20年,这套自研Java Web框架支撑过省级新农保、全国跨省医保结算等核心民生系统,18年稳定运行至今。这篇复盘在政务系统里实现 WebRTC 远程视频帮办的完整过程,全是实战踩坑后的记录,不求优雅但求落地。最后感谢豆包、智谱、OpenCode,决策是我做的,代码是我搓的,文字是他们总结的。


背景

政务大厅来了个老百姓,要办一个业务。窗口人员一看材料,缺一份关键证明。老百姓说证明在家里的电脑上。怎么办?

以前的做法:老百姓回家拿材料,再来一趟。政务大厅可能离家里几十公里。

现在:窗口人员发起一个远程视频通话,老百姓在家用手机接听,屏幕共享把证明材料展示出来,窗口人员远程帮办,全程录屏录音存档备查。

这就是政务远程帮办。核心功能:

  1. 视频通话------窗口人员和老百姓面对面沟通
  2. 屏幕共享------老百姓展示电子材料
  3. 录屏录音------全程存档,事后可查
  4. 和业务系统集成------接听后自动打开对应的业务表单

为什么自己做

远程帮办不是新概念,市面上有现成的厂家方案。但是他们一般不负责本地化。

政务系统的需求是定制化的:视频通话要和业务表单联动,接听后要自动加载对应的业务页面,录屏录音要按政务存档标准保存。厂家的基础版做不到这些,定制修改?加钱。工期?等排期。

不是想不想买的问题,是买不起也等不起。

整体架构

复制代码
┌──────────────────┐         ┌──────────────────┐
│   窗口人员端       │         │   老百姓端(手机)    │
│                  │         │                  │
│  本地视频 ──────┐  │         │  ┌────── 远程视频   │
│  远程视频 ←────┤  │  WebRTC  │  ├────── 本地视频   │
│  录屏(webm)     │◄────────►│  │                  │
│  录音(wav)      │  P2P    │  │                  │
│  业务表单       │         │  │                  │
└──────┬──────────┘         └──────┬──────────┘
       │                           │
       │    ┌──────────────┐       │
       └───►│  信令服务器     │◄──────┘
            │  Socket.IO    │
            └──────────────┘
                    │
            ┌───────┴────────┐
            │  TURN 服务器     │
            │  NAT 穿透       │
            └────────────────┘

WebRTC 本身是 P2P 的,但需要两个辅助服务:

  • 信令服务器Socket.IO):交换 SDP 和 ICE Candidate,协调双方建立连接
  • TURN 服务器:当双方网络环境不允许直连时(比如政务外网和手机 4G 之间),通过 TURN 服务器中转流量

第一步:信令服务器------Socket.IO

WebRTC 建立连接之前,双方需要交换"我支持什么编码、我的网络地址是什么"这些信息。WebRTC 本身不定义信令传输方式,我们用 Socket.IO

连接信令服务器

javascript 复制代码
var socket = null;

function conn() {
    socket = io('https://xxx.xxx.gov.cn', { path: '/signal/socket.io' });

    socket.on('joined', (roomid, id) => {
        state = 'joined';
        createPeerConnection();
        bindTracks();
    });

    socket.on('otherjoin', (roomid) => {
        if (state === 'joined_unbind') {
            createPeerConnection();
            bindTracks();
        }
        state = 'joined_conn';
        call();
    });

    socket.on('full', (roomid, id) => {
        socket.disconnect();
        hangup();
        closeLocalMedia();
        state = 'leaved';
        alert('当前正在办理业务,暂无法接通!');
    });

    socket.on('bye', (room, id) => {
        state = 'joined_unbind';
        guaduan();
        endBtnClick();
        endBtnClick_y();
    });

    socket.on('message', (roomid, data) => {
        // 处理 SDP 和 ICE Candidate(后面展开)
    });

    roomid = sessionid;
    socket.emit('create', roomid);
}

关键设计:用 sessionid 作为房间号。窗口人员的 session 是唯一的,老百姓扫码或输入编号后加入同一个房间,形成一对一通话。

信令状态机

复制代码
init → joined → joined_conn → joined_unbind → leaved
                ↑                |
                └────────────────┘(对方离开后重新等待)
  • init:初始状态
  • joined:自己已加入房间,创建 PeerConnection
  • joined_conn:对方也加入了,发起 call(createOffer)
  • joined_unbind:对方离开,解绑 track,但保留连接等待下一个
  • leaved:自己离开,断开 socket

full 状态处理了"防重入"------同一个房间只能有两个人。如果第三个人想加入,直接拒绝。政务场景下,一个窗口同一时间只能服务一个老百姓。


第二步:采集本地音视频

javascript 复制代码
function start() {
    var constraints = {
        video: {
            width: 640,
            height: 480
        },
        audio: {
            echoCancellation: true,
            noiseSuppression: true,
            autoGainControl: true
        }
    };

    navigator.mediaDevices.getUserMedia(constraints)
        .then(getMediaStream)
        .catch(handleError);
}

三个音频参数是政务窗口环境的刚需:

  • echoCancellation:回声消除。政务大厅有音响,不消除回声对方会听到自己的声音
  • noiseSuppression:降噪。大厅人多嘈杂
  • autoGainControl:自动增益。老百姓在家可能离手机远,自动调大音量
javascript 复制代码
function getMediaStream(stream) {
    localStream = stream;
    loStream = stream;

    localVideo.srcObject = localStream;

    // 一定要在 getMediaStream 之后调用
    // 否则绑定失败
    conn();
}

loStream 是专门给录音用的本地流副本。reStream 是远程流。分开存储是因为录屏和录音需要不同的流。


第三步:建立 P2P 连接

ICE 配置

javascript 复制代码
var pcConfig = {
    iceServers: [{
        urls: ['turn:xxx.xxx.xxx.xxx:3478'],
        credential: "******",
        username: "******"
    }],
    iceTransportPolicy: 'all',
    bundlePolicy: 'max-bundle',
    rtcpMuxPolicy: 'require'
};

政务网络环境的特殊性:政务外网和互联网之间有多层 NAT/防火墙,STUN 直连基本不可能。所以必须配 TURN 服务器做流量中转。iceTransportPolicy: 'all' 表示 STUN 和 TURN 都用,优先尝试直连,不行再走中转。

创建 PeerConnection

javascript 复制代码
function createPeerConnection() {
    if (!pc) {
        pc = new RTCPeerConnection(pcConfig);

        pc.onicecandidate = (e) => {
            if (e.candidate) {
                sendMessage(roomid, {
                    type: 'candidate',
                    label: e.candidate.sdpMLineIndex,
                    id: e.candidate.sdpMid,
                    candidate: e.candidate.candidate
                });
            }
        };

        pc.ontrack = getRemoteStream;
    }
}

onicecandidate:每发现一个网络地址候选,就通过信令服务器发给对方。

ontrack:收到对方的媒体轨道(视频或音频),触发远程视频显示。

绑定本地媒体轨道

javascript 复制代码
function bindTracks() {
    localStream.getTracks().forEach((track) => {
        pc.addTrack(track, localStream);
    });
}

信令消息处理------SDP 交换

javascript 复制代码
socket.on('message', (roomid, data) => {
    if (data.hasOwnProperty('type') && data.type === 'offer') {
        pc.setRemoteDescription(new RTCSessionDescription(data));
        pc.createAnswer()
            .then(getAnswer)
            .catch(handleAnswerError);

    } else if (data.hasOwnProperty('type') && data.type == 'answer') {
        pc.setRemoteDescription(new RTCSessionDescription(data));

    } else if (data.hasOwnProperty('type') && data.type === 'candidate') {
        var candidate = new RTCIceCandidate({
            sdpMLineIndex: data.label,
            candidate: data.candidate
        });
        pc.addIceCandidate(candidate);

    } else if (data.hasOwnProperty('type') && data.type === 'CARID') {
        // 自定义消息:传递老百姓的身份证号和业务ID
        callPhone.textContent = data.label.aac002;
        ywid = data.label.yw_id;
    }
});

除了标准的 SDP 和 ICE 消息,还用 Socket.IO 传了一个自定义的 CARID 消息------老百姓扫码加入时,把身份证号和业务 ID 传过来,窗口人员端自动显示来电人信息。

发起呼叫(Offer)

javascript 复制代码
function call() {
    if (state === 'joined_conn') {
        var offerOptions = {
            offerToRecieveAudio: 1,
            offerToRecieveVideo: 1
        };

        pc.createOffer(offerOptions)
            .then(getOffer)
            .catch(handleOfferError);
    }
}

function getOffer(desc) {
    pc.setLocalDescription(desc);
    offerdesc = desc;
    sendMessage(roomid, offerdesc);
}

function getAnswer(desc) {
    pc.setLocalDescription(desc);
    sendMessage(roomid, desc);
}

标准流程:呼叫方 createOffer → setLocalDescription → 通过信令发给对方 → 对方 setRemoteDescription → createAnswer → setLocalDescription → 通过信令发回来 → 呼叫方 setRemoteDescription。连接建立。

接收远程视频

javascript 复制代码
function getRemoteStream(e) {
    if (reStream == null) {
        remoteStream = e.streams[0];
        remoteVideo.srcObject = e.streams[0];
        reStream = e.streams[0];

        reStream.getTracks().forEach((track) => {
            pc.addTrack(track, reStream);
        });

        btnClick();    // 开始录屏
        btnClick_y();  // 开始录音
    }
}

reStream == null 判断是因为 ontrack 事件可能触发多次(视频轨道 + 音频轨道各触发一次)。只在第一次触发时初始化,避免重复创建录像器。

远程视频到达后立即开始录屏录音------确保不遗漏任何内容。


第四步:录屏------MediaRecorder

javascript 复制代码
var rebuffer;
var mediaRecorder;

function startRecord() {
    rebuffer = [];
    var options = {
        mimeType: 'video/webm;codecs=vp8'
    };
    if (!MediaRecorder.isTypeSupported(options.mimeType)) {
        console.error(options.mimeType + ' is not supported!');
        return;
    }
    mediaRecorder = new MediaRecorder(reStream, options);
    mediaRecorder.ondataavailable = handleDataAvailable;
    mediaRecorder.start(10);
}

function handleDataAvailable(e) {
    if (e && e.data && e.data.size > 0) {
        rebuffer.push(e.data);
    }
}

mediaRecorder.start(10) 每 10 毫秒触发一次 ondataavailable,把数据片段存到 rebuffer 数组里。

为什么录的是 reStream(远程流)而不是 localStream?因为政务存档的重点是老百姓展示的材料和说的话,不是窗口人员自己。

下载时把 buffer 拼成一个完整的 webm 文件:

javascript 复制代码
function BtnDownload() {
    var blob = new Blob(rebuffer, { type: 'video/webm' });
    var url = window.URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.href = url;
    a.style.display = 'none';
    const timestamp = getTime();
    a.download = timestamp + '_local.webm';
    a.click();
}

文件名用时间戳命名,格式如 20260407143025123_local.webm,方便事后归档检索。


第五步:录音------AudioContext + 手工拼 WAV

录屏用 MediaRecorder 很方便,但录屏只能拿到 webm 格式。政务存档要求音频单独存一份,而且最好是 WAV 格式(通用、无压缩、可转写文字)。

浏览器没有直接的"WAV 录音 API",需要用 AudioContext 从底层采集 PCM 数据,然后手工拼 WAV 文件头

采集 PCM 数据

javascript 复制代码
var audioContext = null;
var source = null;
var processor = null;
var recorded = [];

function startRecord_y() {
    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    source = audioContext.createMediaStreamSource(loStream);
    processor = audioContext.createScriptProcessor(4096, 1, 1);

    processor.onaudioprocess = (e) => {
        const left = e.inputBuffer.getChannelData(0);
        const left16 = new Int16Array(left.length);
        for (let i = 0; i < left.length; i++) {
            left16[i] = left[i] * 0x7FFF;
        }
        recorded.push(left16);
    };

    source.connect(processor);
    processor.connect(audioContext.destination);
}

处理流程:

  1. createMediaStreamSource 从本地音频流创建音频源
  2. createScriptProcessor(4096, 1, 1) 创建处理器:每 4096 帧触发一次,单声道输入输出
  3. onaudioprocess 回调里把浮点音频(-1.0 ~ 1.0)转成 16 位整数(-32768 ~ 32767),存到 recorded 数组

为什么 left[i] * 0x7FFF?AudioContext 给的是 32 位浮点 PCM,WAV 要的是 16 位整数 PCM。0x7FFF = 32767,乘以它就完成了浮点到整数的缩放。

本地和远程各录一份

javascript 复制代码
function btnClick_y() {
    startRecord_y();        // 本地录音(loStream)
    startRecordRemote_y();  // 远程录音(reStream)
}

远程录音用的是 reStream,代码结构一样,只是变量名加了 _r 后缀。

手工拼 WAV 文件头

这是最关键的部分。WAV 文件格式是固定的 44 字节头部 + PCM 数据。浏览器不会帮你生成这个头,必须自己拼:

javascript 复制代码
function writeWavHeader(view, sampleRate, totalAudioLength) {
    writeString(view, 0, 'RIFF');
    view.setUint32(4, 36 + totalAudioLength * 2, true);
    writeString(view, 8, 'WAVE');

    writeString(view, 12, 'fmt ');
    view.setUint32(16, 16, true);
    view.setUint16(20, 1, true);
    view.setUint16(22, 1, true);
    view.setUint32(24, sampleRate, true);
    view.setUint32(28, sampleRate * 2, true);
    view.setUint16(32, 2, true);
    view.setUint16(34, 16, true);

    writeString(view, 36, 'data');
    view.setUint32(40, totalAudioLength * 2, true);
}

function writeString(view, offset, string) {
    for (let i = 0; i < string.length; i++) {
        view.setUint8(offset + i, string.charCodeAt(i));
    }
}

逐字节解释:

偏移 大小 含义
0 4 "RIFF" 文件标识
4 4 36 + 数据长度 文件总大小 - 8
8 4 "WAVE" 格式标识
12 4 "fmt " 格式块标识
16 4 16 fmt 块大小
20 2 1 PCM 格式(无压缩)
22 2 1 单声道
24 4 sampleRate 采样率(如 48000)
28 4 sampleRate × 2 字节速率(采样率 × 声道数 × 每样本字节数)
32 2 2 块对齐(声道数 × 每样本字节数)
34 2 16 位深度(16 bit)
36 4 "data" 数据块标识
40 4 数据长度 音频数据的字节数

下载时组装:

javascript 复制代码
function BtnDownload_y() {
    const sampleRate = audioContext.sampleRate;
    const totalAudioLength = recorded.reduce((acc, buffer) => acc + buffer.length, 0);
    const buffer = new ArrayBuffer(44 + totalAudioLength * 2);
    const view = new DataView(buffer);

    writeWavHeader(view, sampleRate, totalAudioLength);

    let offset = 44;
    for (let i = 0; i < recorded.length; i++) {
        for (let j = 0; j < recorded[i].length; j++) {
            view.setInt16(offset, recorded[i][j], true);
            offset += 2;
        }
    }

    const blob = new Blob([view], { type: 'audio/wav' });
    var url = window.URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.href = url;
    a.style.display = 'none';
    const timestamp = getTime();
    a.download = timestamp + '_local.wav';
    a.click();
}

先算出录音数据的总长度,创建一个 ArrayBuffer(44 字节头 + 音频数据),写完头部后从第 44 字节开始逐个写入 16 位整数 PCM 数据。true 参数表示小端序(little-endian),这是 WAV 文件的标准字节序。


第六步:和政务业务系统集成

视频通话不是孤立的------窗口人员接听后,要自动打开对应的业务表单。

来电弹框

当老百姓发起视频呼叫时,窗口人员端弹出呼叫框(videoCall.jsp),显示来电号码和姓名,三个按钮:接听、挂断、呼叫转接。

接听后自动加载业务表单

javascript 复制代码
function answer1() {
    document.getElementById('passport-login-container').style.display = "none";

    var ds_yw = new browise.ds.DataStore("table");
    ds_yw.setParameter("yw_id", ywid);
    var dc = new browise.ds.DataCenter();
    dc.addDataStore(ds_yw);

    var URL = webRootDir + "/BusinessAction?Business=matter&Action=selectMatter";
    var data = {
        url: URL,
        sync: true,
        load: function(dc1) {
            var ds2 = dc1.getDataStore("matter");
            var title_name = ds2.getRowSet(0).getData()[0].yw_name;
            form_id = ds2.getRowSet(0).getData()[0].form_id;

            browise.byId("yd_pane").setTitle(title_name + "信息");

            var yw = document.getElementsByName("ywshow");
            for (var i = 0; i < yw.length; i++) {
                yw[i].style.display = 'none';
            }
            document.getElementById(form_id).style.display = "";
        }
    };
    browise.Action.requestData(data, dc, true);

    // 发送 allow 指令(点击接听才发送)
    socket.emit('allow', roomid);
    // 停止呼叫铃声
    audio.pause();
    audio.currentTime = 0;
}

流程:

  1. 隐藏来电弹框
  2. yw_id(老百姓扫码时传过来的业务 ID)查询业务信息
  3. 拿到业务名称和对应的表单 ID
  4. 隐藏所有业务表单区域,显示对应的那一个
  5. 发送 allow 信令,老百姓端收到后视频画面开始传输

视频通话和业务表单在同一个页面上------左边是视频窗口,右边是业务表单。窗口人员边看老百姓展示的材料,边录入业务信息。


第七步:挂断和资源释放

javascript 复制代码
function guaduan() {
    leave();
    connSignalServer();
    audio.pause();
    audio.currentTime = 0;
    document.getElementById('passport-login-container').style.display = "none";
}

function leave() {
    socket.emit('kick', roomid);
    socket.emit('leave', roomid);
    hangup();
    closeLocalMedia();
    reStream = null;
    mediaRecorder = null;
}

function hangup() {
    if (!pc) return;
    offerdesc = null;
    pc.close();
    pc = null;
}

function closeLocalMedia() {
    if (!(localStream === null || localStream === undefined)) {
        localStream.getTracks().forEach((track) => {
            track.stop();
        });
    }
    localStream = null;
}

挂断后不是直接断开,而是 leave()connSignalServer()------先释放旧的连接,再重新建立信令连接。这样窗口人员始终保持在"等待来电"状态,下一个老百姓可以直接呼入。


踩过的坑

1. ontrack 触发多次

WebRTC 的 ontrack 事件会为每个媒体轨道(视频、音频)各触发一次。如果不加判断,会创建多个录像器,导致录屏数据混乱。解决方案:

javascript 复制代码
if (reStream == null) {
    // 只在第一次触发时初始化
}

2. 绑定 Track 的时机

bindTracks() 必须在 getMediaStream() 之后调用。如果先创建了 PeerConnection 但还没拿到本地流,addTrack 会失败:

javascript 复制代码
function getMediaStream(stream) {
    localStream = stream;
    localVideo.srcObject = localStream;
    conn();  // 拿到流之后再连接信令
}

3. 政务网络的 NAT 穿透

政务外网有严格的防火墙策略,STUN 直连几乎不可能。必须配 TURN 服务器做流量中转。iceTransportPolicy 要设为 'all',不能设为 'relay'(那样会强制所有流量走 TURN,延迟大)。

4. ScriptProcessor 已废弃但还能用

AudioContext.createScriptProcessor 在最新规范里已经标记为废弃,推荐用 AudioWorklet。但 AudioWorklet 的兼容性在政务系统常见的浏览器环境里还不够好(尤其是老旧的 IE 兼容模式)。先用 ScriptProcessor 能跑就行。

5. 远程流不能直接录屏

最开始 startRecord() 传的是 localStream,结果录的是自己的画面。改成 reStream(远程流)后才正确录制老百姓的画面和声音。


决策原则

不追求技术先进,只追求业务闭环。

WebRTC 的 API 不复杂,但要做到"政务能用":

  • 信令、穿透、录屏、录音、业务集成,一个都不能少
  • WAV 文件头虽然可以靠库生成,但手工拼更可控,不引入额外依赖
  • 录屏录音和业务表单同步打开,确保每一步都有据可查

政务系统的核心要求是全程留痕。老百姓和窗口人员的每一次远程交互,视频、音频、业务数据都要完整保存。这不是技术炫技,是合规要求。

买不起厂家的方案,就自己搓------和DLL医保接口、手写IOC、JDBC游标导出是同一个逻辑:可控性比便利性重要,买不来的就自己做。


你在项目里做过 WebRTC 的实战吗?录音是自己拼 WAV 还是用现成的库?欢迎评论区聊聊。


作者:许彰午 | 非科班野生程序员,深耕政务信息化20年

标签: #Java #WebRTC #视频通话 #录屏 #录音 #WAV #Socket.IO #政务信息化

相关推荐
无忧智库2 小时前
智库级深度复盘:政务云智慧灾备解决方案——从“烟囱式备份”到“服务化运营”的韧性重构(PPT)
政务
无忧智库2 小时前
数字政府智慧政务解决方案:构建“云网端”协同的数字化服务体系(PPT)
政务
音视频牛哥12 小时前
国产化最后一公里:鸿蒙 NEXT 低延迟音视频技术方案破局之路
音视频·harmonyos·鸿蒙next·鸿蒙rtmp播放器·鸿蒙rtsp播放器·鸿蒙next rtsp播放器·鸿蒙next rtmp播放器
EasyDSS14 小时前
私有化音视频系统/视频高清点播直播EasyDSS如何解锁文旅行业数字化传播新路径
音视频
苏黎caius17 小时前
SoX 语句,音频界的瑞士军刀
音视频
v1326656236817 小时前
博通集成:BK7259 wifi6音视频芯片 200w视频流IPC 超低功耗
物联网·音视频·低功耗·ipc
v1326656236819 小时前
博通集成:BK7259 支持200w视频流IPC 带ISP 硬件H264编解码 本地算力0.1T
物联网·音视频·ipc·ai边缘
纳祥科技19 小时前
拆解一款AUX立体声音频切换器,4进1出,乐器/便携效果器均可用
音视频
coder阿龙20 小时前
基于PeerJS实现网页WebRTC屏幕分享
webrtc