浏览器网页播放A律音频实时流方案

需求

录音监听,将持续产生的A律码流传输到网页上播放,产生A律码流的位置与web服务不在一个地方。

方案

方案一:A律转PCM,Web Audio API

将A律码流字节数组通过UDP方式发送给web服务,web服务将UDP包推送到前端页面播放,前端网页通过web audio api播放。

web服务端

启动线程监听UDP端口,一旦收到数据,则通过websocket推送给收听了该音频的前端。

浏览器端

进入页面马上跟服务端建立websocket长连接,当接收到音频的消息数据时,开始播放音频。

1、实现A律数据转为PCM数据并播放

转码过程包括如下步骤:

  1. 1、A律字节数组 → 16位PCM字节数组(s16le,小端序)
  2. 2、转为int16Array(解析小端序)
  3. 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服务端

  1. 启动线程监听UDP端口,设置静态队列,一旦监听到数据,则将数据放入队列中。
  2. 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分钟),并且后端处理复杂,所以最终选择方案一。

相关推荐
深圳市友昊天创科技有限公司3 小时前
ASM4242 雷电扩展坞 CV4242 PCIE扩展坞
音视频·实时音视频·视频编解码
IT陈图图4 小时前
Flutter × OpenHarmony 实战:从 0 构建视频播放器的分类导航模块
flutter·华为·音视频·openharmony
深圳市友昊天创科技有限公司5 小时前
友昊天创推出延长器方案GSV5600+HDBase VS010**/VS100**
音视频·实时音视频·视频编解码
线束线缆组件品替网5 小时前
Stewart Connector RJ45 以太网线缆高速接口设计解析
服务器·网络·人工智能·音视频·硬件工程·材料工程
runner365.git5 小时前
语言接入大模型,websocket还是webrtc?
websocket·网络协议·webrtc
IT陈图图6 小时前
构建跨端视频播放器中的“推荐视频”模块:Flutter × OpenHarmony 实战解析
flutter·音视频·鸿蒙·openharmony
IT陈图图6 小时前
Flutter × OpenHarmony 跨端视频播放器实战:自定义视频控制栏设计与实现
flutter·音视频·鸿蒙·openharmony
东华果汁哥6 小时前
【机器视觉 视频截帧算法】OpenCV 视频截帧算法教程
opencv·算法·音视频
码路星河14 小时前
基于 Vue + VueUse 的 WebSocket 优雅封装:打造高可用的全局连接管理方案
javascript·vue.js·websocket