需求
录音监听,将持续产生的A律码流传输到网页上播放,产生A律码流的位置与web服务不在一个地方。
方案
方案一:A律转PCM,Web Audio API
将A律码流字节数组通过UDP方式发送给web服务,web服务将UDP包推送到前端页面播放,前端网页通过web audio api播放。
web服务端
启动线程监听UDP端口,一旦收到数据,则通过websocket推送给收听了该音频的前端。
浏览器端
进入页面马上跟服务端建立websocket长连接,当接收到音频的消息数据时,开始播放音频。
1、实现A律数据转为PCM数据并播放
转码过程包括如下步骤:
- 1、A律字节数组 → 16位PCM字节数组(s16le,小端序)
- 2、转为int16Array(解析小端序)
- 3、16位PCM(s16le)转为Float32Array
javascript
/**
* 初始化G.711 A-law解码表(标准算法,输出16位线性PCM)
*/
function initAlawDecodeTable() {
const table = new Int16Array(256);
for (let i = 0; i < 256; i++) {
// 步骤1:反转A律的符号位(A律编码时的异或操作还原)
let byte = i ^ 0x55;
// 步骤2:提取符号位、指数位、尾数位
const sign = (byte & 0x80) >> 7; // 符号位(0=正,1=负)
const exponent = (byte & 0x70) >> 4; // 指数位(4位)
const mantissa = byte & 0x0F; // 尾数位(4位)
// 步骤3:解码为线性PCM
let sample;
if (exponent === 0) {
sample = mantissa << 4; // 指数为0时,尾数直接左移4位
} else {
sample = (mantissa + 16) << (exponent + 3); // 指数非0时扩展
}
// 步骤4:应用符号位
sample = sign === 0 ? sample : -sample;
// 步骤5:存储为16位PCM(范围[-32768, 32767])
table[i] = sample;
}
return table;
}
const ALAW_DECODE_TABLE = initAlawDecodeTable()
/**
* 核心解码函数:单个8位A律字节 → 16位PCM短整型(s16le)
* @param {byte} alawByte - 8位A律字节(无符号)
* @returns {number} 16位线性PCM值
*/
function alawByteToPcm16(alawByte) {
const unsignedByte = alawByte & 0xFF; // 转为无符号8位
return ALAW_DECODE_TABLE[unsignedByte];
}
/**
* 批量解码:A律字节数组 → 16位PCM字节数组(s16le,小端序)
* @param {Uint8Array} alawData - 8位A律字节数组
* @returns {Uint8Array} 16位PCM字节数组(小端序)
*/
function alawToPcm16Bytes(alawData) {
const pcmBytes = new Uint8Array(alawData.length * 2); // 16位=2字节/样本
for (let i = 0; i < alawData.length; i++) {
const pcm16 = alawByteToPcm16(alawData[i]);
// 转为小端序字节(低字节在前,高字节在后)
pcmBytes[2 * i] = pcm16 & 0xFF; // 低8位
pcmBytes[2 * i + 1] = (pcm16 >> 8) & 0xFF; // 高8位
}
return pcmBytes;
}
/**
* 16位PCM(s16le)→ Float32
* @param {Int16Array} pcm16Array - 16位PCM数组
* @returns {Float32Array} Float32数组(范围[-1, 1])
*/
function pcm16ToFloat32(pcm16Array) {
const float32 = new Float32Array(pcm16Array.length);
for (let i = 0; i < pcm16Array.length; i++) {
// 步骤1:放大PCM幅值(增益)
let amplified = pcm16Array[i];
// 步骤2:限制范围,避免削波(超过±32768会失真)
amplified = Math.max(-32768, Math.min(32767, amplified));
// 步骤3:映射到Float32的[-1,1]
float32[i] = amplified / 32768;
}
return float32;
}
export default function alawArrayToPcmFloat(data) {
const alawData = new Uint16Array(data)
//A律 -> 16位PCM字节
const pcm16Bytes = alawToPcm16Bytes(alawData)
//转为int16Array(解析小端序)
const pcm16Array = new Int16Array(pcm16Bytes.buffer)
//转为Float32
return pcm16ToFloat32(pcm16Array)
}
然后是调用web audio api播放的方法:
javascript
var audioContext = null
function playAlawData(data) {
const channels = 2 //通道数
const sampleRate = 8000//采样率
const float32Data = alawArrayToPcmFloat(data)
// 兼容不同浏览器
const AudioContext = window.AudioContext || window.webkitAudioContext;
if(AudioContext) {
if (!audioContext) {
audioContext = new AudioContext({sampleRate: 8000});
}
const audioBuffer = audioContext.createBuffer(channels, 1024, sampleRate)
for (let channel = 0; channel < channels; channel++) {
const channelData = audioBuffer.getChannelData(channel);
// 对于多声道,需要将数据分配到不同声道
for (let i = 0; i < channelData.length; i++) {
//注意这里的取数据方法要根据data内容来定
channelData[i] = float32Data[channelData.length * channel + i];
}
}
//创建音频源节点
const source = audioContext.createBufferSource()
source.buffer = audioBuffer
ctx.audioBuffer = audioBuffer
//连接到输出(扬声器)
source.connect(audioContext.destination)
source.start()
}
}
2、上面的方案只能一次性播放,为实现播放持续流,使用ScriptProcessor
持续往队列audioBufferQueue中塞数据,当ScriptProcessor中数据播放完时从audioBufferQueue取数据,当没有数据时填充0。
javascript
var audioContext = null
var scriptProcessor = []
const audioBufferQueue = []
function pushData2Audio(data) {//websocket推送数据
const channels = 2 //通道数
const sampleRate = 8000//采样率
const float32Data = alawArrayToPcmFloat(data)
// 兼容不同浏览器
const AudioContext = window.AudioContext || window.webkitAudioContext;
if(AudioContext) {
if (!audioContext) {
audioContext = new AudioContext({sampleRate: 8000});
const scriptProcessor = audioContext.createScriptProcessor(float32Data.length/channels, channels, channels) //三个参数含义:缓冲区大小、输入音频通道数、输出音频通道数
scriptProcessor.onaudioprocess = (event) => {
if(audioBufferQueue.length == 0) { //如果没有数据,则填充0
const outputBuffer = event.outputBuffer
for(let channel=0; channel<outputBuffer.numbersOfChannels; channel++) {
const channelData = outputBuffer.getChannelData(channel)
channelData.fill(0)
}
return
}
const outputBuffer = event.outputBuffer
const currentData = audioBufferQueue[0]
let dataOffset = 0
for(let channel=0; channel<outputBuffer.numberOfChannels; channel++) {
const channelData = outputBuffer.getChannelData(channel)
for(let i=0; i<channelData.length; i++) {
if(dataOffset < currentData.length) {
channelData[i] = currentData[dataOffset]
dataOffset+=1
} else {
channelData[i] = 0
}
}
}
//跳过已经读取的数据
if(dataOffset >= currentData.length-1) {
audioBufferQueue.shift()
} else {
audioBufferQueue[0] = currentData.slice(dataOffset)
}
}
scriptProcessor.connect(audioContext.destination)
}
audioBufferQueue.push(float32Data)
}
}
为实现实时接收数据,建立websocket连接,在接收到数据后调用pushData2Audio方法。
javascript
let websocket = null
if ('WebSocket' in window) {
websocket = new WebSocket(url) //url为websocket地址
}
websocket.onerror = function(event) {
console.log('error', event)
}
websocket.onopen = function(event) {
console.log('服务已连接', event, new Date())
}
websocket.onclose = function(event) {
console.log('close', event)
}
websocket.onmessage = function(res) {
pushData2Audio(res.data)
}
方案二:后端转流,前端用audio标签播放
将A律码流字节数组通过UDP方式发送到web服务,web服务将A律码流转为wav媒体流,前端网页上通过audio标签指向该流
web服务端
- 启动线程监听UDP端口,设置静态队列,一旦监听到数据,则将数据放入队列中。
- web服务提供一个请求,可以获得可播放的音频流
浏览器端
audio的src设置为web服务的音频流接口地址。
以下代码使用SpringMVC框架接受请求并返回流。通过ffmpeg对队列中的音频字节流进行处理,转为webm格式,因为浏览器直接播放wav格式流会有最大30KB的限制。
java
//音频队列,UDP接收道的数据会推送到这个队列中
public static ArrayBlockingQueue<byte[]> queue = new ArrayBlockingQueue<byte[]>(100);
@GetMapping("continuousWav")
public ResponseEntity<StreamingResponseBody> getContinuousWavStream(String ip, Integer channel) {
HttpHeaders headers = new HttpHeaders();
// 核心:分块传输 + WAV 格式
headers.setContentType(MediaType.parseMediaType("audio/webm; codecs=opus"));
headers.add("Transfer-Encoding", "chunked"); //分段,因为音频数据的接收、发送会不及时
headers.add("Connection", "keep-alive"); //无限流
headers.add("Cache-Control", "no-cache, no-store"); //不要缓存
headers.add("Pragma", "no-cache"); //不要缓存
StreamingResponseBody responseBody = outputStream -> {
ProcessBuilder pb = new ProcessBuilder(ffmpegPath,
"-f", "alaw", //源格式: A律
"-ar", "8000", //采样率 8000
"-ac", "1", //通道数 1
"-re",
"-i", "-", //输入流:标准输入
"-f", "webm", //转为格式webm
"-fflags", "nobuffer", //不要缓存
"-flags", "low_delay", //低延迟
"-flush_packets", "1", //及时刷新包
"-c:a", "libopus", //编码方法
"-b:a", "16k",
"-loglevel", "info", //日志级别
"-"); //输出流:标准输出
pb.redirectErrorStream(false);
Process ffmpegProcess = pb.start();
new Thread(() -> {
try (BufferedReader errReader = new BufferedReader(new InputStreamReader(ffmpegProcess.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = errReader.readLine()) != null) {
log.error("FFmpeg 日志:" + line);
}
} catch (IOException e) {
log.error("读取 FFmpeg 错误日志失败:" + e.getMessage());
}
}).start();
//获取ffmpeg的输入/输出流
OutputStream ffmpegStdin = ffmpegProcess.getOutputStream();
InputStream ffmpegStdout = ffmpegProcess.getInputStream();
new Thread(() -> {
while(audioQueueMap.containsKey(ip + "-" + channel)) {
try {
byte[] data = queue.poll(1l, TimeUnit.SECONDS);
if(data!=null) {
ffmpegStdin.write(data);
ffmpegStdin.flush();
}
} catch (IOException e) {
log.error("写流失败" + e.getMessage(), e);
} catch (InterruptedException e) {
log.error("写流失败" + e.getMessage(), e);
}
}
}).start();
byte[] wavBuffer = new byte[1024];
int wavBytesRead = 0;
try {
while((wavBytesRead = ffmpegStdout.read(wavBuffer))!=-1) {
//跳过ffmpeg输出的wav头
if(outputStream instanceof ByteArrayOutputStream) {
outputStream.write(wavBuffer, 44, wavBytesRead - 44);
} else {
outputStream.write(wavBuffer, 0, wavBytesRead);
}
outputStream.flush();
}
} catch(IOException e) {
log.error("读流失败" + e.getMessage(), e);
ffmpegProcess.destroy();
}
log.debug("readdata:{}", wavBytesRead);
ffmpegProcess.destroy();
outputStream.close();
};
return new ResponseEntity<>(responseBody, headers, HttpStatus.OK);
}
前端通过audio标签播放的代码:
javascript
const audio = document.getElementById('audio')
audio.preload = 'none'
audio.autobuffer = false
audio.src = //后端音频流地址
await audio.play()
audio.addEventListener('canplay', async () => {
await audio.play()
})
audio.addEventListener('error', (e, err) => {
console.error('播放失败', e, err)
})
// 监听缓冲区状态,确认持续读取
audio.addEventListener('progress', () => {
const buffered = audio.buffered;
if (buffered.length > 0) {
const end = buffered.end(0);
console.log('已缓冲时长:', end, '秒'); // 会持续增长
}
});
audio.onended = function() {
console.log('onended')
}
该方案前端处理简单,但是开始播放很长的延迟(1分钟),并且后端处理复杂,所以最终选择方案一。