js录制音频

avatar
作者
猴君
阅读量:0

整体思路

1、监听用户onTouchStart事件,设置一个定时器记录是否长按,然后调用JSBridge开始录制事件
2、通过JSBridge监听录音过程,拿到录音的数据,前端用数组变量存放
3、监听用户onTouchEnd松开事件,清除定时器,处理录音数组转换成一个文件上传到oss

难点

难点一:将base64录音片段转为WAV文件
首先将一系列Base64编码的音频段合并成一个PCM数据流;
然后创建一个WAV文件的头部信息;
最后合并WAV文件头和PCM数据

难点二:TypedArray数据的合并
TypedArray: 存储的是固定类型的数值数据,如整数或浮点数。
Array: 可以存储任何类型的数据,包括数字、字符串、对象等

开始录音

  /**    * 开始录音    */   const handleTouchStart = (event) => {     event.preventDefault();     timerId = setTimeout(() => {       setLongPress(true);       console.log('handleTouchStart 长按了');       JSBridge(XX.startRecording', {         numberOfChannels: 1, // 声道数         // sampleRate: 16000, // 采样率         sampleRate: 44100, // 更改采样率为 44100 Hz         bitsPerChannel: 16, // 位深         format: 'PCM',       }).then(() => {         setRecordStatus('dialog_listening');       });     }, 100); // 长按时长,这里设置为100ms   }; 

监听录音过程

 const onRecordChange = (event) => {     console.log(event);      const { error, param } = event || {};     const { pcm } = param || {};     const { errorCode, errorMsg } = error || {};      if (errorCode) {       Toast.show({         type: 'error',         content: `录制失败,${errorMsg}`,       });       baseArrayRef.current = [];     } else {       baseArrayRef.current.push(pcm);     }   };    useEffect(() => {     document.addEventListener('RecordingDataBufferTransfer', onRecordChange);      return () => {       // 清除长按定时器       if (timerId !== null) clearTimeout(timerId);     };   }, []); 

结束录制

/**    * 结束录音    * @returns    */   const handleTouchEnd = (event) => {     if (timerId !== null) {       clearTimeout(timerId)       timerId = null     }     if (!longPress) return;     setLongPress(false);     console.log('handleTouchEnd 松开了');     JSBridge('XX.stopRecording').then(() => {       // 移除事件监听器       document.removeEventListener(         'RecordingDataBufferTransfer',         onRecordChange,       );       setRecordStatus('dialog_sleep');       onMerge();     });   }; 

音频波动动画

VoiceAnimation/index.tsx

import cls from 'classnames'; import debounce from 'lodash/debounce'; import { useLayoutEffect, useMemo, useRef } from 'react'; import styles from './index.module.less';  interface IProps {   status: string; } export default function (props: IProps) {   const { status = 'dialog_sleep' } = props;   const list = useMemo(() => new Array(5).fill(true), []);    return (     <div className={cls(styles.voice, status)}>       {list.map((_, index) => (         <AnimationItem status={status} index={index} />       ))}     </div>   ); }  function getTransationByStatus(status: string, index?) {   return {     dialog_sleep: {       transition: 'all 0.3s',       height: '8px',       transform: 'translateY(0)',     },     dialog_idle: {       transition: 'all 0.3s',       height: '8px',       transform: 'translateY(0)',     },     dialog_listening: {       transition: 'all 0.3s',       height: '24px',       transform: index % 2 ? 'translateY(8px)' : 'translateY(-8px)',       onTransitionEnd: debounce(         (event) => {           if (             event.target.parentElement.className.indexOf('dialog_listening') ===             -1           )             return;           event.target.style.transitionDuration = '0.5s';           event.target.style.height = '24px';           event.target.style.transform =             event.target.style.transform === 'translateY(8px)'               ? 'translateY(-8px)'               : 'translateY(8px)';         },         {           leading: true,           trailing: false,         },       ),     },     dialog_thinking: {       transition: 'all 0.3s',       height: `${[52, 44, 36, 28, 24][index]}px`,       transform: 'translateY(0)',       onTransitionEnd: debounce(         (event) => {           if (             event.target.parentElement.className.indexOf('dialog_thinking') ===             -1           )             return;           event.target.style.transitionDuration = '0.5s';           event.target.style.height = {             '52px': '24px',             '44px': '28px',             '36px': '32px',             '32px': '36px',             '28px': '44px',             '24px': '52px',           }[event.target.style.height];         },         {           leading: true,           trailing: false,         },       ),     },     dialog_responding: {       transition: 'all 0.2s',       height: `${Math.random() * (index + 1) * 10 + 24}px`,       transform: 'translateY(0)',       onTransitionEnd: debounce(         (event) => {           if (             event.target.parentElement.className.indexOf(               'dialog_responding',             ) === -1           )             return;           event.target.style.transitionDuration = '0.15s';           event.target.style.height = `${Math.random() * (index + 1) * 10 + 24}px`;         },         {           leading: true,           trailing: false,         },       ),     },   }[status]; }  function AnimationItem({ status, index }: { status: string; index?: number }) {   const div = useRef<any>();    useLayoutEffect(() => {     const container = div.current as HTMLDivElement;     function reset() {       container.ontransitionend = (e) => {};       container.style.transition = 'all .1s';       container.style.height = '24px';       container.style.transform = 'translateY(0)';     }      reset();      const { onTransitionEnd = () => {}, ...style } =       getTransationByStatus(status, index) || {};      container.ontransitionend = onTransitionEnd;      for (let prop in style) {       container.style[prop] = style[prop];     }      return () => {};   }, [status]);    return (     <div ref={div} className={styles.item} style={{ width: 24, height: 24 }} />   ); }  

VoiceAnimation/index.module.less

.voice {   display: flex;   justify-content: center;   align-items: center;   height: 56px;    .item {     // width: 24px;     // height: 24px;     background-color: var(--TY-Text-Brand-1);     border-radius: 20px;     margin: 0 4px;     transform: translateY(0);   } }  .loop(@n, @i: 0) when (@i <= @n) {   &:nth-child(@{i}) {     animation-delay: (@i * 0.2s);   }   .loop(@n, (@i + 1)); } 

一个完整的音频录制——播放的例子

<!DOCTYPE html> <html lang="en">  <head>   <meta charset="UTF-8">   <meta http-equiv="X-UA-Compatible" content="ie=edge">   <title>pcmtowav</title> </head>  <body>   <div>     getUserMedia需要https,使用localhost或127.0.0.1时,可用http。   </div>   <button id="start">开始录音</button>   <button id="end">结束录音</button>   <button id="play">播放录音</button> </body> <script>   var context = null,     inputData = [],     size = 0,     audioInput = null,     recorder = null,     dataArray;    document.getElementById('start').addEventListener('click', function () {     context = new (window.AudioContext || window.webkitAudioContext)();     // 清空数据     inputData = [];     // 录音节点     recorder = context.createScriptProcessor(4096, 1, 1);      recorder.onaudioprocess = function (e) {       var data = e.inputBuffer.getChannelData(0);        inputData.push(new Float32Array(data));       size += data.length;     }      navigator.mediaDevices.getUserMedia({       audio: true     }).then((stream) => {       audioInput = context.createMediaStreamSource(stream);      }).catch((err) => {       console.log('error');     }).then(function () {       audioInput.connect(recorder);       recorder.connect(context.destination);     });   });   document.getElementById('end').addEventListener('click', function () {     recorder.disconnect();   });   document.getElementById('play').addEventListener('click', function () {     recorder.disconnect();     if (0 !== size) {       // 组合数据       // var data = combine(inputData, size);		       inputSampleRate = context.sampleRate;       context.decodeAudioData(encodeWAV().buffer, function (buffer) {         // decodeAudioData,是支持promise,三参数的知识兼容老的         playSound(buffer);       }, function () {         console.log('error');       });       // console.log(data.buffer);     }   });   // ----------------------   // 以下是增加的内容    var inputSampleRate = 0;   // 输入采样率   var oututSampleBits = 16;  // 输出采样数位    // 数据简单处理   function decompress() {     // 合并     var data = new Float32Array(size);     var offset = 0; // 偏移量计算     // 将二维数据,转成一维数据     for (var i = 0; i < inputData.length; i++) {       data.set(inputData[i], offset);       offset += inputData[i].length;     }     return data;   };   function encodePCM() {     let bytes = decompress(),       sampleBits = oututSampleBits,       offset = 0,       dataLength = bytes.length * (sampleBits / 8),       buffer = new ArrayBuffer(dataLength),       data = new DataView(buffer);      // 写入采样数据      if (sampleBits === 8) {       for (var i = 0; i < bytes.length; i++, offset++) {         // 范围[-1, 1]         var s = Math.max(-1, Math.min(1, bytes[i]));         // 8位采样位划分成2^8=256份,它的范围是0-255; 16位的划分的是2^16=65536份,范围是-32768到32767         // 因为我们收集的数据范围在[-1,1],那么你想转换成16位的话,只需要对负数*32768,对正数*32767,即可得到范围在[-32768,32767]的数据。         // 对于8位的话,负数*128,正数*127,然后整体向上平移128(+128),即可得到[0,255]范围的数据。         var val = s < 0 ? s * 128 : s * 127;         val = parseInt(val + 128);         data.setInt8(offset, val, true);       }     } else {       for (var i = 0; i < bytes.length; i++, offset += 2) {         var s = Math.max(-1, Math.min(1, bytes[i]));         // 16位直接乘就行了         data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);       }     }      return data;   }    function encodeWAV() {     var sampleRate = inputSampleRate;     var sampleBits = oututSampleBits;     var bytes = encodePCM();           var buffer = new ArrayBuffer(44 + bytes.byteLength);     var data = new DataView(buffer);      var channelCount = 1;   // 单声道     var offset = 0;      // 资源交换文件标识符      writeString(data, offset, 'RIFF'); offset += 4;     // 下个地址开始到文件尾总字节数,即文件大小-8      data.setUint32(offset, 36 + bytes.byteLength, true); offset += 4;     // WAV文件标志     writeString(data, offset, 'WAVE'); offset += 4;     // 波形格式标志      writeString(data, offset, 'fmt '); offset += 4;     // 过滤字节,一般为 0x10 = 16      data.setUint32(offset, 16, true); offset += 4;     // 格式类别 (PCM形式采样数据)      data.setUint16(offset, 1, true); offset += 2;     // 通道数      data.setUint16(offset, channelCount, true); offset += 2;     // 采样率,每秒样本数,表示每个通道的播放速度      data.setUint32(offset, sampleRate, true); offset += 4;     // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8      data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); offset += 4;     // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8      data.setUint16(offset, channelCount * (sampleBits / 8), true); offset += 2;     // 每样本数据位数      data.setUint16(offset, sampleBits, true); offset += 2;     // 数据标识符      writeString(data, offset, 'data'); offset += 4;     // 采样数据总数,即数据总大小-44      data.setUint32(offset, bytes.byteLength, true); offset += 4;      // 给wav头增加pcm体     for (let i = 0; i < bytes.byteLength; ++i) {       data.setUint8(offset, bytes.getUint8(i, true), true);       offset++;     }      return data;   }    function getWAVBlob() {     return new Blob([encodeWAV()], { type: 'audio/wav' });   }   function playSound(buffer) {     var source = context.createBufferSource();      // 设置数据     source.buffer = buffer;     // connect到扬声器     source.connect(context.destination);     source.start();   }    function writeString(data, offset, str) {     for (var i = 0; i < str.length; i++) {       data.setUint8(offset + i, str.charCodeAt(i));     }   }   function combineDataView(resultConstructor, ...arrays) {     let totalLength = 0,       offset = 0;     // 统计长度     for (let arr of arrays) {       totalLength += arr.length || arr.byteLength;     }     // 创建新的存放变量     let buffer = new ArrayBuffer(totalLength),       result = new resultConstructor(buffer);     // 设置数据     for (let arr of arrays) {       // dataview合并       for (let i = 0, len = arr.byteLength; i < len; ++i) {         result.setInt8(offset, arr.getInt8(i));         offset += 1;       }     }      return result;   } </script>  </html> 

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!