StreamingContext.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. // /common/StreamingContext.js
  2. import BlockingQueue from './BlockingQueue.js'; // 使用我们之前适配过的版本
  3. /**
  4. * 适配uniapp小程序环境的音频流处理上下文。
  5. * 它负责解码Opus流,将其缓冲为PCM数据,并在需要时转换为WAV文件进行播放。
  6. */
  7. export class StreamingContext {
  8. /**
  9. * @param {object} opusDecoder - 已初始化的Opus解码器实例
  10. * @param {InnerAudioContext} innerAudioContext - uniapp创建的音频播放器实例
  11. * @param {number} sampleRate - 采样率 (e.g., 16000)
  12. * @param {number} channels - 声道数 (e.g., 1)
  13. */
  14. constructor(opusDecoder, innerAudioContext, sampleRate, channels) {
  15. this.opusDecoder = opusDecoder;
  16. this.innerAudioContext = innerAudioContext;
  17. this.sampleRate = sampleRate;
  18. this.channels = channels;
  19. // 输入队列,用于接收来自WebSocket的原始Opus数据包
  20. this.inputQueue = new BlockingQueue();
  21. // 内部PCM缓冲,存储解码后的Int16数据块
  22. this._pcmBuffer = [];
  23. this._totalPcmSamples = 0;
  24. // 状态标记
  25. this._isDecoding = false;
  26. this._isStopped = false;
  27. }
  28. /**
  29. * 外部调用:向队列中添加待解码的原始Opus数据。
  30. * @param {Uint8Array} opusFrame - 从WebSocket收到的Opus数据帧
  31. */
  32. pushOpusFrame(opusFrame) {
  33. if (opusFrame && opusFrame.length > 0) {
  34. this.inputQueue.enqueue(opusFrame);
  35. }
  36. }
  37. /**
  38. * 启动解码循环。这个循环会持续运行,直到被stop()。
  39. * 它会阻塞地等待`inputQueue`中的数据。
  40. */
  41. async startDecodingLoop() {
  42. if (this._isDecoding) return; // 防止重复启动
  43. if (!this.opusDecoder) {
  44. console.log('Opus解码器未初始化,无法启动解码循环', 'error');
  45. return;
  46. }
  47. this._isDecoding = true;
  48. this._isStopped = false;
  49. console.log('Opus解码循环已启动', 'info');
  50. while (!this._isStopped) {
  51. try {
  52. // 阻塞式地等待数据帧,可以一次性取出多个
  53. const framesToDecode = await this.inputQueue.dequeue(1, Infinity);
  54. if (this._isStopped) break; // 检查在等待后是否被停止
  55. for (const frame of framesToDecode) {
  56. const pcmFrame = this.opusDecoder.decode(frame); // 假设返回 Int16Array
  57. if (pcmFrame && pcmFrame.length > 0) {
  58. this._pcmBuffer.push(pcmFrame);
  59. this._totalPcmSamples += pcmFrame.length;
  60. }
  61. }
  62. } catch (error) {
  63. console.log(`解码循环中出错: ${error.message}`, 'error');
  64. }
  65. }
  66. this._isDecoding = false;
  67. console.log('Opus解码循环已停止', 'info');
  68. }
  69. /**
  70. * 外部调用:将当前所有缓冲的PCM数据转换成WAV文件并播放。
  71. */
  72. async playBufferedAudio() {
  73. if (this.innerAudioContext.paused === false) {
  74. console.log('播放器正在播放,本次播放请求被忽略', 'warning');
  75. return;
  76. }
  77. if (this._pcmBuffer.length === 0) {
  78. console.log('PCM缓冲区为空,无需播放', 'info');
  79. return;
  80. }
  81. // 1. 合并所有PCM数据块
  82. const totalSamples = this._totalPcmSamples;
  83. const fullPcmData = new Int16Array(totalSamples);
  84. let offset = 0;
  85. for (const pcmChunk of this._pcmBuffer) {
  86. fullPcmData.set(pcmChunk, offset);
  87. offset += pcmChunk.length;
  88. }
  89. // 清空缓冲区以便下一次会话
  90. this.reset();
  91. console.log(`准备播放,总样本数: ${totalSamples}`, 'info');
  92. // 2. 转换为WAV格式 (ArrayBuffer)
  93. const wavData = this._pcmToWav(fullPcmData, this.sampleRate, this.channels);
  94. // 3. 写入临时文件并播放
  95. const fs = wx.getFileSystemManager();
  96. const tempFilePath = `${wx.env.USER_DATA_PATH}/temp_audio_${Date.now()}.wav`;
  97. fs.writeFile({
  98. filePath: tempFilePath,
  99. data: wavData,
  100. encoding: 'binary',
  101. success: () => {
  102. console.log(`WAV文件写入成功: ${tempFilePath}`, 'success');
  103. this.innerAudioContext.src = tempFilePath;
  104. this.innerAudioContext.play();
  105. },
  106. fail: (err) => {
  107. console.log(`WAV文件写入失败: ${JSON.stringify(err)}`, 'error');
  108. }
  109. });
  110. }
  111. /**
  112. * 重置上下文状态,清空所有缓冲区,为下一次语音会话做准备。
  113. */
  114. reset() {
  115. this._pcmBuffer = [];
  116. this._totalPcmSamples = 0;
  117. // 注意:不要重置 inputQueue,因为它可能有预读的数据
  118. console.log('StreamingContext已重置', 'info');
  119. }
  120. /**
  121. * 停止解码循环并清理资源。
  122. */
  123. stop() {
  124. this._isStopped = true;
  125. // 通过入队一个null值来唤醒可能在dequeue上阻塞的循环,使其能检查到_isStopped标志
  126. this.inputQueue.enqueue(null);
  127. }
  128. /**
  129. * [核心辅助函数] 将Int16 PCM数据转换为WAV文件格式的ArrayBuffer
  130. * @param {Int16Array} pcmData - 原始PCM数据
  131. * @param {number} sampleRate - 采样率
  132. * @param {number} channels - 声道数
  133. * @returns {ArrayBuffer}
  134. */
  135. _pcmToWav(pcmData, sampleRate, channels) {
  136. const bitsPerSample = 16;
  137. const dataSize = pcmData.length * (bitsPerSample / 8);
  138. const fileSize = 44 + dataSize;
  139. const buffer = new ArrayBuffer(fileSize);
  140. const view = new DataView(buffer);
  141. // 写入WAV文件头
  142. // RIFF chunk descriptor
  143. this._writeString(view, 0, 'RIFF');
  144. view.setUint32(4, fileSize - 8, true); // fileSize
  145. this._writeString(view, 8, 'WAVE');
  146. // "fmt " sub-chunk
  147. this._writeString(view, 12, 'fmt ');
  148. view.setUint32(16, 16, true); // chunkSize
  149. view.setUint16(20, 1, true); // audioFormat (1 for PCM)
  150. view.setUint16(22, channels, true); // numChannels
  151. view.setUint32(24, sampleRate, true); // sampleRate
  152. view.setUint32(28, sampleRate * channels * (bitsPerSample / 8), true); // byteRate
  153. view.setUint16(32, channels * (bitsPerSample / 8), true); // blockAlign
  154. view.setUint16(34, bitsPerSample, true); // bitsPerSample
  155. // "data" sub-chunk
  156. this._writeString(view, 36, 'data');
  157. view.setUint32(40, dataSize, true);
  158. // 写入PCM数据
  159. for (let i = 0; i < pcmData.length; i++) {
  160. view.setInt16(44 + i * 2, pcmData[i], true);
  161. }
  162. return buffer;
  163. }
  164. _writeString(view, offset, string) {
  165. for (let i = 0; i < string.length; i++) {
  166. view.setUint8(offset + i, string.charCodeAt(i));
  167. }
  168. }
  169. }
  170. /**
  171. * 创建StreamingContext实例的工厂函数
  172. * @param {object} opusDecoder
  173. * @param {InnerAudioContext} innerAudioContext
  174. * @param {number} sampleRate
  175. * @param {number} channels
  176. * @returns {StreamingContext}
  177. */
  178. export function createStreamingContext(opusDecoder, innerAudioContext, sampleRate, channels) {
  179. return new StreamingContext(opusDecoder, innerAudioContext, sampleRate, channels);
  180. }