diff --git a/src/App.vue b/src/App.vue index 633a5dfe4e547c48bfa93740a290ba5ba370930a..f292c18b134af07254ee53bdd37013d586a9c3e5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,47 +1,61 @@ diff --git a/src/utils/recorder.js b/src/utils/recorder.js new file mode 100644 index 0000000000000000000000000000000000000000..510a42a26b4130391425161f121ece4de94a271c --- /dev/null +++ b/src/utils/recorder.js @@ -0,0 +1,188 @@ + +import recordWorker from './transcode.worker' + +const URL = window.URL || window.webkitURL // 获取浏览器API + +// 加载并启动 record worker +let workerString = recordWorker.toString() +// 移除函数包裹 +workerString = workerString.substr(workerString.indexOf("{") + 1) +workerString = workerString.substr(0, workerString.lastIndexOf("}")) +const workerBlob = new Blob([workerString]) +const workerURL = URL.createObjectURL(workerBlob) +const worker = new Worker(workerURL) + +/** + * class IatRecorder 语音听写类 + * @param {Object} config 参数 + */ +class IatRecorder { + constructor(config) { + this.status = "null" // 当前录音的状态 null为开始 ing为录音中 end为结束 + + // 记录音频数据 + this.audioData = [] + this.buffer = [] + // 记录听写结果 + worker.onmessage = (event) => { + this.audioData.push(...event.data) + console.log('处理后的数据长度', this.audioData.length) + } + } + // 修改录音听写状态 + setStatus (status) { + this.onWillStatusChange && + this.status !== status && + this.onWillStatusChange(this.status, status) + this.status = status + } + // 初始化浏览器录音 + recorderInit () { + navigator.getUserMedia = + navigator.getUserMedia || + navigator.webkitGetUserMedia || + navigator.mozGetUserMedia || + navigator.msGetUserMedia + + // 创建音频环境 + try { + this.audioContext = new (window.AudioContext || + window.webkitAudioContext)() + console.log('audioContext sampleRate:', this.audioContext.sampleRate) + console.log('UA', navigator.userAgent) + if (!this.audioContext) { + alert("浏览器不支持webAudioApi相关接口,请前往Chrome,FireFox浏览器体验") + return + } + } catch (e) { + if (!this.audioContext) { + alert("浏览器不支持webAudioApi相关接口,请前往Chrome,FireFox浏览器体验") + return + } + } + + // 获取浏览器录音权限 + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + navigator.mediaDevices + .getUserMedia({ + audio: true, + video: false, + }) + .then((stream) => { + getMediaSuccess(stream) + }) + .catch((e) => { + getMediaFail(e) + }) + } else if (navigator.getUserMedia) { + navigator.getUserMedia( + { + audio: true, + video: false, + }, + (stream) => { + getMediaSuccess(stream) + }, + function (e) { + getMediaFail(e) + } + ) + } else { + if ( + navigator.userAgent.toLowerCase().match(/chrome/) && + location.origin.indexOf("https://") < 0 + ) { + alert( + "chrome下获取浏览器录音功能,因为安全性问题,需要在localhost或127.0.0.1或https下才能获取权限" + ) + } else { + alert("无法获取浏览器录音功能,请升级浏览器或使用chrome") + } + this.audioContext && this.audioContext.close() + return + } + // 获取浏览器录音权限成功的回调 + let getMediaSuccess = (stream) => { + // 创建一个用于通过JavaScript直接处理音频 + this.scriptProcessor = this.audioContext.createScriptProcessor( + 4096, + 1, + 1 + ) + this.scriptProcessor.onaudioprocess = (e) => { + // 去处理音频数据 + this.buffer.push(...e.inputBuffer.getChannelData(0)) + console.log('录音原始数据长度,类型为 Float32Array', e.inputBuffer.getChannelData(0).length) + worker.postMessage({ + command: "transform", + buffer: e.inputBuffer.getChannelData(0), + is16K: this.audioContext.sampleRate, + }) + } + // 创建一个新的MediaStreamAudioSourceNode 对象,使来自MediaStream的音频可以被播放和操作 + this.mediaSource = this.audioContext.createMediaStreamSource(stream) + // 连接 + this.mediaSource.connect(this.scriptProcessor) + this.scriptProcessor.connect(this.audioContext.destination) + }; + + let getMediaFail = () => { + alert("请求麦克风失败") + this.audioContext && this.audioContext.close() + this.audioContext = undefined + } + } + + /** + * 开始录音 + */ + recorderStart () { + this.setStatus("ing") + this.recorderStop() + this.recorderInit() + } + /** + * 暂停录音 + * @param e + */ + recorderStop (e) { + // this.download(); + this.setStatus("end") + // 关闭录音 + this.audioContext && this.audioContext.close() + this.audioContext = undefined + } + + getBuffer () { + let output = this.to16BitPCM(this.buffer) + return output + } + to16BitPCM (input) { + var dataLength = input.length * (16 / 8) + var dataBuffer = new ArrayBuffer(dataLength) + var dataView = new DataView(dataBuffer) + var offset = 0 + for (var i = 0; i < input.length; i++, offset += 2) { + var s = Math.max(-1, Math.min(1, input[i])) + dataView.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true) + } + return dataView + } + download () { + const blob = new Blob([this.getBuffer()]) //处理文档流 + if ("msSaveOrOpenBlob" in navigator) { + window.navigator.msSaveOrOpenBlob(blob, "test.pcm") + return + } + const elink = document.createElement("a") + elink.download = "test.pcm" + elink.style.display = "none" + elink.href = URL.createObjectURL(blob) + document.body.appendChild(elink) + elink.click() + URL.revokeObjectURL(elink.href) // 释放URL 对象 + document.body.removeChild(elink) + } +} + +export default IatRecorder diff --git a/src/utils/transcode.worker.js b/src/utils/transcode.worker.js new file mode 100644 index 0000000000000000000000000000000000000000..f52e93270a9470b4d7a538196e10095ee9fc9256 --- /dev/null +++ b/src/utils/transcode.worker.js @@ -0,0 +1,112 @@ +const recordWorker = function () { + let self = this; + this.onmessage = function (e) { + switch (e.data.command) { + case "transform": + transform.transaction(e.data, e.data.is16K); + break; + } + }; + + let transform = { + transaction(audioData, is16K) { + let output; + if (is16K === 16000) { + output = transform.to16BitPCM(audioData.buffer, is16K); + } else if (is16K > 16000) { + output = transform.to16kHz(audioData.buffer, is16K); + output = transform.to16BitPCM(output); + } else if (is16K < 16000) { + output = transform.lessTo16kHz(audioData.buffer, is16K); + output = transform.to16BitPCM(output); + } + output = Array.from(new Uint8Array(output.buffer)); + self.postMessage(output); + }, + /** + * 大于16kHz降采样到16kHz + * @param audioData + * @param originalSampleRate + * @return {*|Float32Array} + */ + to16kHz(audioData, originalSampleRate) { + if (originalSampleRate <= 16000) { + return audioData; + } + + let data = new Float32Array(audioData); + let fitCount = Math.round(data.length * (16000 / originalSampleRate)); + let newData = new Float32Array(fitCount); + let springFactor = (data.length - 1) / (fitCount - 1); + + newData[0] = data[0]; + for (let i = 1; i < fitCount - 1; i++) { + let tmp = i * springFactor; + let before = Math.floor(tmp).toFixed(); + let after = Math.ceil(tmp).toFixed(); + let atPoint = tmp - before; + newData[i] = data[before] + (data[after] - data[before]) * atPoint; + } + newData[fitCount - 1] = data[data.length - 1]; + + return newData; + }, + /** + * 小于16kHz升采样到16kHz + * @param audioData + * @param originalSampleRate + * @return {*|Float32Array} + */ + lessTo16kHz(audioData, originalSampleRate) { + // 如果原始采样率已经是16kHz或更高,直接返回原始数据 + if (originalSampleRate >= 16000) { + return audioData; + } + // 计算新的采样率与原始采样率的比例 + const upsampleFactor = 16000 / originalSampleRate; + // 计算新的数据长度 + const newLength = Math.ceil(audioData.length * upsampleFactor); + // 创建一个新的 Float32Array 用于存储升采样后的数据 + const newData = new Float32Array(newLength); + // 进行最近邻插值 + for (let i = 0; i < newLength; i++) { + // 计算原始数据中对应的样本索引 + const originalIndex = Math.floor(i / upsampleFactor); + // 将原始样本值复制到新数据中 + newData[i] = audioData[originalIndex]; + } + return newData; + }, + to16BitPCM(input) { + let dataLength = input.length * (16 / 8); // 计算了将要创建的缓冲区的长度,这里使用了位操作来表示字节数,即 16 / 8 表示每个样本占用2个字节(16位PCM格式)。 + let dataBuffer = new ArrayBuffer(dataLength); // 是一个用于存储二进制数据的固定大小的缓冲区,其长度为 dataLength 字节。 + let dataView = new DataView(dataBuffer); // 则是一个用于读写 dataBuffer 中数据的视图,通过它可以以不同的数据格式(如整数、浮点数)访问缓冲区中的数据。 + + let offset = 0; // offset 变量用于追踪当前写入到 dataView 中的位置。初始为0,每次迭代增加2,因为每个16位样本占用2个字节。 + for (let i = 0; i < input.length; i++, offset += 2) { + let s = Math.max(-1, Math.min(1, input[i])); // Math.max(-1, Math.min(1, input[i])) 确保将输入值限制在 -1 到 1 之间,因为16位PCM格式只能表示在 -32768 到 32767 之间的整数。 + // dataView.setInt16(offset, value, littleEndian) 将处理过的音频数据写入到 dataView 中: + // offset 是数据在缓冲区中的偏移量。 + // value 是通过将输入数据映射到16位有符号整数的方式得到的数值。 + // littleEndian 参数设为 true,表示使用小端序(低位字节在前,高位字节在后)存储数据,这是常见的音频格式存储方式。 + dataView.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); + } + return dataView; + }, + to8BitPCM(input) { + var dataLength = input.length; + var dataBuffer = new ArrayBuffer(dataLength); + var dataView = new DataView(dataBuffer); + var offset = 0; + for (var i = 0; i < input.length; i++, offset++) { + var s = Math.max(-1, Math.min(1, input[i])); + var val = s < 0 ? s * 0x80 : s * 0x7F; + var intVal = Math.round(val); // Round to nearest integer + dataView.setInt8(offset, intVal); + } + return dataView; + }, + }; +}; + +export default recordWorker;