This commit is contained in:
Home
2025-12-01 03:42:34 +08:00
parent 492a164bff
commit fde86ef902
1917 changed files with 21835 additions and 214147 deletions
@@ -0,0 +1,18 @@
## 2.0.52025-08-29
修复示例中的引用错误和新增描述
## 2.0.42025-08-29
修复示例引用赋值回调函数错误的问题
## 2.0.32025-05-16
新增使用说明
## 2.0.22025-05-16
1. 新增结束回调函数
2. 新增主动结束播放
3. 修改为vue2写法(renderjs层回调逻辑层方法必需)
## 2.0.12025-04-25
优化对爆破音和重音的处理
## 2.0.02025-04-24
之前版本是收到数据转成本地mp3文件进行播放,在切换播放时会有卡顿,且不支持pcm格式,这次更换技术栈解决这个问题。
1. 新增StreamPlayer类,可以兼容安卓、iOS、h5播放pcm二进制流。
2.新增H5AndAndriod类,可以在安卓、h5播放pcm、mp3二进制流。由于iOS不支持MSE所以暂不支持iOS。
## 1.0.02025-03-21
1. 实现在h5和app中播放二进制音频流
@@ -0,0 +1,121 @@
<template>
<button type="default" @click="connect">播放音频</button>
<button type="default" @click="end">结束播放</button>
<!-- 逻辑层和renderjs的交互需要通过这种方式传递数据具体看文档 -->
<view :isStop="isStop" :change:isStop="renderJS.stop" :prop="currBuffer" :change:prop="renderJS.playTTS" type="renderjs" module="renderJS"></view>
</template>
<script>
/**
* 本页面代码为仅供参考的案例,只列举了实现思路,是我从项目代码中抽出来的,具体是否能跑通需要测试
* 核心代码在plugins
*/
let ws = null
export default {
data(){
return {
currBuffer: null,
isStop: false,
isStreamPlaying: false
}
},
methods: {
connect() {
// 这里链接socket或者sse,并把接受到的二进制数据不断调用即可, 具体的ws请自行封装
// ws = uni.connectSocket({
// url: 'ws://10.10.10.201:8000/ws/audio',
// method: 'GET',
// success() {},
// fail(e) {
// console.log(e);
// }
// });
// ws.onMessage(arrayBuffer => {
// console.log('arrayBuffer', arrayBuffer);
// this.isStreamPlaying = true
// this.currBuffer= uni.arrayBufferToBase64(arrayBuffer)
// })
this.ws = uni.connectSocket({
url: 'ws://10.10.10.201:8000/ws/audio',
complete: () => {
console.log('complete');
},
success: () => {
console.log('web');
this.ws.onOpen((res) => {
console.log('WebSocket连接已打开', res);
this.ws.onMessage((res) => {
let messageData = res.data;
// 处理不同类型的数据
if (typeof messageData === 'string') {
// 文本数据
try {
const parsedData = JSON.parse(messageData);
this.handleMessage(parsedData);
} catch (e) {
this.handleMessage(messageData);
}
} else if (messageData instanceof ArrayBuffer) {
// 二进制数据
this.isStreamPlaying = true
this.currBuffer= uni.arrayBufferToBase64(messageData)
try {
// const str = this.arrayBufferToStringCompat(messageData);
// console.log('转换后的字符串:', str);
} catch (e) {
console.log('二进制数据解析失败:', e);
}
}
})
});
}})
},
end() {
this.isStop = !this.isStop
},
changeStreamPlaying(e) {
this.isStreamPlaying = e.type
}
}
}
</script>
<script module="renderJS" lang="renderjs">
/**
* 如果不需要支持iOS,建议使用这个,同时支持mp3和pcm
*/
// import { StreamPlayer } from "../../plugins/H5AndAndroidStreamPlayer";
/**
* 如果需要支持安卓iOS和H5,使用这个,支持pcm,暂时不支持mp3
* 如果想支持mp3可以自己开发,我在测试中mp3的效果太差所以放弃了。
*/
import { StreamPlayer } from "../../plugins/StreamPlayer";
let player=null
export default {
mounted() {
const _this = this;
player = new StreamPlayer({callback: _this.callback});
},
methods: {
playTTS (base64) {
if(!base64)return
const binaryStr = atob(base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
player.appendChunk(bytes.buffer);
},
stop() {
player&&player.destroy()
player = null
},
callback(e) {
if(e==='ended') {
this.$ownerInstance.callMethod('changeStreamPlaying', {type: false});
}
}
}
}
</script>
@@ -0,0 +1,109 @@
{
"id": "sdx-StreamPlayer",
"displayName": "TTS音频二进制流 PCM边收边播【安卓、iOS、H5】",
"version": "2.0.5",
"description": "流式播放二进制PCM音频文件,实现边收边播",
"keywords": [
"音频播放,流式数据"
],
"repository": "",
"engines": {
"HBuilderX": "^4.57",
"uni-app": "^4.28",
"uni-app-x": ""
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "文件读写权限"
},
"npmurl": "",
"darkmode": "x",
"i18n": "x",
"widescreen": "x"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": {
"extVersion": "2.0.2",
"minVersion": ""
},
"vue3": {
"extVersion": "2.0.2",
"minVersion": ""
}
},
"web": {
"safari": {
"extVersion": "2.0.1",
"minVersion": ""
},
"chrome": {
"extVersion": "2.0.1",
"minVersion": ""
}
},
"app": {
"vue": "√",
"nvue": "-",
"android": "√",
"ios": "√",
"harmony": "-"
},
"mp": {
"weixin": "-",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}
@@ -0,0 +1,72 @@
/**
* 二进制音频播放-h5api
* 在app中使用renderjs调用
* 因为iOS不支持MediaSource,所以暂不支持iOS
*/
export class StreamPlayer {
constructor() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
this.mediaSource = new MediaSource()
this.audio = new Audio()
this.cacheBuffers = []
this.audio.src = URL.createObjectURL(this.mediaSource)
this.audioContextConnect()
this.listenMedisSource()
}
// 连接音频上下文
audioContextConnect() {
const source = this.audioContext.createMediaElementSource(this.audio)
source.connect(this.audioContext.destination)
}
// 监听媒体资源
listenMedisSource() {
this?.mediaSource.addEventListener('sourceopen', () => {
if (this.sourceBuffer) return
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg')
this.sourceBuffer.addEventListener('update', () => {
if (this.cacheBuffers?.length && !this.sourceBuffer?.updating) {
const cacheBuffer = this.cacheBuffers.shift()
this.sourceBuffer?.appendBuffer(cacheBuffer)
}
this._pauseAudio()
})
})
}
// 暂停音频
_pauseAudio() {
const neePlayTime = this.sourceBuffer.timestampOffset - this.audio.currentTime || 0
this.pauseTimer && clearTimeout(this.pauseTimer)
// 播放完成5秒后还没有新的音频流过来,则暂停音频播放
this.pauseTimer = setTimeout(() => this.audio.pause(), neePlayTime * 1000 + 5000)
}
_playAudio() {
// 为防止下一段音频流传输过来时,上一段音频已经播放完毕,造成音频卡顿现象,
// 这里做了1秒的延时,可根据实际情况修正
setTimeout(() => {
if (this.audio.paused) {
try {
this.audio.play()
} catch (e) {
this._playAudio()
}
}
}, 1000)
}
// 接收音频数据
appendChunk(audioData) {
if (!audioData?.byteLength) return
if (this.sourceBuffer?.updating) {
this.cacheBuffers.push(audioData)
} else {
this.sourceBuffer.appendBuffer(audioData)
}
this._playAudio()
}
}
@@ -0,0 +1,200 @@
/**
* 支持安卓、iOS、H5的pcm格式,
* 如果想支持mp3,需要增加mp3相关解码合并处理等,个人测试效果不太好,所以没有加
*/
export class StreamPlayer {
constructor({
inputSampleRate = 16000,
numChannels = 1,
bitDepth = 16,
littleEndian = true,
pcmType = 'int',
callback = ()=>{}
} = {}) {
// 参数校验
if (![16, 32].includes(bitDepth)) throw new Error('bitDepth 必须是 16 或 32');
if (inputSampleRate <= 0) throw new Error('采样率必须大于 0');
if (!['int', 'float'].includes(pcmType)) throw new Error('pcmType 必须是 int 或 float');
this.audioContext = new(window.AudioContext || window.webkitAudioContext)();
this.inputSampleRate = inputSampleRate;
this.numChannels = numChannels;
this.bitDepth = bitDepth;
this.littleEndian = littleEndian;
this.pcmType = pcmType;
this.audioQueue = [];
this.playbackEndTime = 0;
this.currentSource = null;
this.lastBlockTail = null; // 用于交叉淡入淡出
this.callback = callback;
}
appendChunk(audioData) {
this.audioQueue.push(audioData);
this._processQueue();
}
destroy() {
this.audioQueue = [];
this.playbackEndTime = 0;
if (this.currentSource) {
this.currentSource.stop();
this.currentSource = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
}
async _processQueue() {
if(this.audioQueue.length === 0) {
this.callback('ended')
return
}
if (!this.audioContext || this.currentSource) return;
const buffer = this._mergeArrayBuffers(this.audioQueue)
this.audioQueue = []
try {
const audioBuffer = this._convertPCM(buffer);
await this._schedulePlay(audioBuffer);
this._processQueue();
} catch (err) {
console.error('音频处理失败:', err);
}
}
_convertPCM(buffer) {
// 1. 数据对齐:确保长度是样本大小的整数倍
const bytesPerSample = this.bitDepth / 8;
const requiredBytes = bytesPerSample * this.numChannels;
const validByteLength = Math.floor(buffer.byteLength / requiredBytes) * requiredBytes;
const validBuffer = buffer.slice(0, validByteLength);
const dataView = new DataView(validBuffer);
const numSamples = validByteLength / requiredBytes;
// 2. 创建音频缓冲区
const audioBuffer = this.audioContext.createBuffer(
this.numChannels,
numSamples,
this.inputSampleRate
);
// 3. 解析PCM数据
for (let ch = 0; ch < this.numChannels; ch++) {
const channelData = audioBuffer.getChannelData(ch);
for (let i = 0; i < numSamples; i++) {
// 计算字节位置(修正后的多声道索引)
const pos = (i * this.numChannels * bytesPerSample) + (ch * bytesPerSample);
let value;
// 根据位深和数据类型解析
if (this.bitDepth === 16) {
value = dataView.getInt16(pos, this.littleEndian) / 32768;
} else {
if (this.pcmType === 'int') {
value = dataView.getInt32(pos, this.littleEndian) / 2147483648;
} else {
value = dataView.getFloat32(pos, this.littleEndian);
}
}
channelData[i] = value;
}
// 4. 消除直流偏移
let sum = 0;
for (let i = 0; i < channelData.length; i++) {
sum += channelData[i];
}
const dcOffset = sum / channelData.length;
for (let i = 0; i < channelData.length; i++) {
channelData[i] -= dcOffset;
}
// 5. 块内淡入淡出(减少边界突变)
const fadeLength = Math.min(100, channelData.length);
for (let i = 0; i < fadeLength; i++) {
channelData[i] *= (i / fadeLength);
}
const startFadeOut = Math.max(0, channelData.length - fadeLength);
for (let i = startFadeOut; i < channelData.length; i++) {
const factor = 1 - (i - startFadeOut) / fadeLength;
channelData[i] *= factor;
}
}
// 6. 块间交叉淡入淡出(解决重音关键点)
const crossfadeLength = 50;
if (this.lastBlockTail && this.lastBlockTail.length === this.numChannels) {
for (let ch = 0; ch < this.numChannels; ch++) {
const channelData = audioBuffer.getChannelData(ch);
const prevTail = this.lastBlockTail[ch];
// 只处理有足够长度交叉的情况
if (prevTail.length >= crossfadeLength && channelData.length >= crossfadeLength) {
for (let i = 0; i < crossfadeLength; i++) {
const prevWeight = (crossfadeLength - i) / crossfadeLength;
const currWeight = i / crossfadeLength;
channelData[i] = channelData[i] * currWeight + prevTail[i] * prevWeight;
}
}
}
}
// 7. 保存当前块尾部用于下一次交叉
this.lastBlockTail = [];
for (let ch = 0; ch < this.numChannels; ch++) {
const channelData = audioBuffer.getChannelData(ch);
const tailStart = Math.max(0, channelData.length - crossfadeLength);
this.lastBlockTail[ch] = channelData.slice(tailStart);
}
return audioBuffer;
}
_schedulePlay(audioBuffer) {
return new Promise(resolve => {
const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.audioContext.destination);
const plannedStartTime = this.playbackEndTime;
const now = this.audioContext.currentTime;
let startTime = Math.max(plannedStartTime, now);
// 动态速率调整(示例,需根据实际测试调整阈值)
const timeDrift = plannedStartTime - now;
if (timeDrift < -0.02) {
source.playbackRate.value = Math.min(1.04, 1 - (timeDrift / audioBuffer.duration));
} else if (timeDrift > 0.02) {
source.playbackRate.value = Math.max(0.96, 1 - (timeDrift / audioBuffer.duration));
} else {
source.playbackRate.value = 1.0;
}
source.start(startTime);
this.playbackEndTime = startTime + (audioBuffer.duration / source.playbackRate.value);
source.onended = () => {
this.currentSource = null;
resolve();
};
this.currentSource = source;
});
}
// 合并多个 ArrayBuffer 的辅助函数
_mergeArrayBuffers(buffers) {
let totalLength = 0;
for (let buffer of buffers) {
totalLength += buffer.byteLength;
}
const result = new Uint8Array(totalLength);
let offset = 0;
for (let buffer of buffers) {
const view = new Uint8Array(buffer);
result.set(view, offset);
offset += view.length;
}
return result.buffer;
}
}
@@ -0,0 +1,7 @@
# sdx-StreamPlayer
最近在调用腾讯的tts进行流式文字转语音实现边收边播放时,遇到了一些问题,查阅很多资料,没有特别系统的解决方案,于是写了个插件,供大家参考。
使用方法:
> 核心文件是plugins/StreamPlayer.js 直接复制到你的项目即可。
> 使用案例在components/sdx-StreamPlayer/sdx-StreamPlayer.vue 直接参照这个写就ok,如果有自己的业务逻辑基于这个调整即可。
@@ -0,0 +1,151 @@
// android.uts(核心逻辑,适配单个类导入规范)
// 导入安卓核心API(和你的电量代码风格对齐)
import Context from "android.content.Context";
import Intent from "android.content.Intent";
import IntentFilter from "android.content.IntentFilter";
import PackageManager from "android.content.pm.PackageManager";
import Build from "android.os.Build";
import Uri from "android.net.Uri";
import MediaStore from "android.provider.MediaStore";
import ActivityCompat from "androidx.core.app.ActivityCompat";
import ContextCompat from "androidx.core.content.ContextCompat";
import FileProvider from "androidx.core.content.FileProvider";
import UniAppActivity from "com.uni.UniAppActivity";
import Log from "android.util.Log";
// 定义回调类型(对外暴露)
export type PhotoCallback = {
success?: (res: { filePath: string; uri: string }) => void;
fail?: (err: { code: number; msg: string }) => void;
complete?: () => void;
};
// 权限请求码(常量)
const REQUEST_CODE_PERMISSION = 1001;
const REQUEST_CODE_PHOTO = 1002;
// 全局存储回调(异步回调用)
let globalCallback: PhotoCallback | null = null;
/**
* 打开安卓相册(适配你的导入规范)
* @param callback 选择图片的回调
*/
export function openAndroidAlbum(callback: PhotoCallback) {
globalCallback = callback;
// 获取Uniapp安卓宿主Activity(核心:替代之前的UTSAndroid.getAppContext
const context = UniAppActivity.getCurrentActivity() as UniAppActivity;
if (!context) {
callback.fail?.({ code: -1, msg: "获取Activity上下文失败" });
callback.complete?.();
globalCallback = null;
return;
}
// 1. 检查权限(安卓6.0+动态申请)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// 适配安卓13+权限:READ_MEDIA_IMAGES 替代存储权限
const permission = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
? "android.permission.READ_MEDIA_IMAGES"
: "android.permission.READ_EXTERNAL_STORAGE";
const permissionStatus = ContextCompat.checkSelfPermission(context, permission);
if (permissionStatus !== PackageManager.PERMISSION_GRANTED) {
// 请求权限
ActivityCompat.requestPermissions(
context,
[permission],
REQUEST_CODE_PERMISSION
);
return;
}
}
// 2. 权限已授权,打开相册
try {
const intent = new Intent(Intent.ACTION_PICK);
intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
// 启动相册选择界面
context.startActivityForResult(intent, REQUEST_CODE_PHOTO);
} catch (e: any) {
callback.fail?.({ code: -1, msg: "打开相册失败:" + e.getMessage() });
callback.complete?.();
globalCallback = null;
}
// 3. 监听权限请求结果
context.setOnRequestPermissionsResultListener((requestCode: number, permissions: string[], grantResults: number[]) => {
if (requestCode === REQUEST_CODE_PERMISSION && globalCallback) {
if (grantResults.length > 0 && grantResults[0] === PackageManager.PERMISSION_GRANTED) {
// 权限成功,重新打开相册
openAndroidAlbum(globalCallback);
} else {
globalCallback.fail?.({ code: 1001, msg: "用户拒绝了图片访问权限,无法打开相册" });
globalCallback.complete?.();
globalCallback = null;
}
}
});
// 4. 监听相册选择结果
context.setOnActivityResultListener((requestCode: number, resultCode: number, data: Intent | null) => {
if (requestCode === REQUEST_CODE_PHOTO && globalCallback) {
try {
if (resultCode === UniAppActivity.RESULT_OK && data != null) {
// 获取选中图片的Uri
const uri = data.getData();
if (uri == null) {
globalCallback.fail?.({ code: 1002, msg: "未选择图片" });
globalCallback.complete?.();
globalCallback = null;
return;
}
// 解析真实文件路径
const filePath = getRealPathFromUri(context, uri);
globalCallback.success?.({
filePath: filePath || "",
uri: uri.toString()
});
} else {
globalCallback.fail?.({ code: 1003, msg: "用户取消选择图片" });
}
} catch (e: any) {
globalCallback.fail?.({ code: -1, msg: "解析图片路径失败:" + e.getMessage() });
} finally {
globalCallback.complete?.();
globalCallback = null;
}
}
});
}
/**
* 将Uri转换为真实文件路径(适配不同安卓版本)
* @param context 上下文
* @param uri 图片Uri
* @returns 真实路径
*/
function getRealPathFromUri(context: Context, uri: Uri): string | null {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 安卓10+ 分区存储,直接返回Uri路径
return uri.getPath() || null;
}
// 低版本解析MediaStore
let cursor = null;
try {
const proj = [MediaStore.Images.Media.DATA];
cursor = context.getContentResolver().query(uri, proj, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
const columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
return cursor.getString(columnIndex);
}
} catch (e: any) {
Log.e("PhotoUtil", "解析路径失败:" + e.getMessage());
} finally {
cursor?.close();
}
return null;
}
@@ -3,13 +3,20 @@
* import { Context } from "android.content.Context";
* [可选实现,按需引入]
*/
import Context from "android.content.Context";
/* 引入 interface.uts 文件中定义的变量 */
import { MyApiOptions, MyApiResult, MyApi, MyApiSync } from '../interface.uts';
/* 引入 unierror.uts 文件中定义的变量 */
import { MyApiFailImpl } from '../unierror';
import UniAppActivity from "com.uni.UniAppActivity";
import Build from 'android.os.Build';
import Intent from 'android.content.Intent';
import Uri from 'android.net.Uri';
import AudioRecord from 'android.media.AudioRecord'
// import { openAndroidAlbum, type PhotoCallback } from "./android.uts";
// export { openAndroidAlbum, PhotoCallback };
/**
* 引入三方库
* [可选实现,按需引入]
@@ -25,7 +32,7 @@ import { MyApiFailImpl } from '../unierror';
/**
* UTSAndroid 为平台内置对象,不需要 import 可直接调用其API[详见](https://uniapp.dcloud.net.cn/uts/utsandroid.html#utsandroid)
*/
const REQUEST_CODE_PHOTO = 1002;
/**
* 异步方法
@@ -51,22 +58,57 @@ import { MyApiFailImpl } from '../unierror';
* myApi(options);
*
*/
// if (options.paramA == true) {
// // 返回数据
// const res : MyApiResult = {
// fieldA: 85,
// fieldB: true,
// fieldC: 'some message'
// };
// options.success?.(res);
// options.complete?.(res);
// } else {
// // 返回错误
// const err = new MyApiFailImpl(9010001);
// options.fail?.(err)
// options.complete?.(err)
// }
export const myApi : MyApi = function (options : MyApiOptions) {
if (options.paramA == true) {
// 返回数据
const res : MyApiResult = {
fieldA: 85,
fieldB: true,
fieldC: 'some message'
};
options.success?.(res);
options.complete?.(res);
} else {
// 返回错误
const err = new MyApiFailImpl(9010001);
options.fail?.(err)
options.complete?.(err)
}
const context = UTSAndroid.getAppContext();
if (context != null) {
const manager = context.getSystemService(
Context.BATTERY_SERVICE
);
console.log('manager', Build.VERSION.SDK_INT, Build.VERSION_CODES.M);
// const intent = new Intent(Intent.ACTION_VIEW, Uri.parse('http://www.baidu.com'));
// context.startActivity(intent);
try {
const intent = new Intent(Intent.ACTION_PICK);
console.log('aa1', AudioRecord.ERROR);
// intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*");
// 启动相册选择界面
// context.startActivityForResult(intent, REQUEST_CODE_PHOTO);
} catch (e : any) {
// console.log("打开相册失败:" + e.getMessage());
}
const res : MyApiResult = {
fieldA: 85,
fieldB: true,
fieldC: 'some message'
};
options.success?.(res)
}
}
/**
@@ -81,15 +123,17 @@ export const myApi : MyApi = function (options : MyApiOptions) {
* 2、方法调用 myApiSync(true)
*/
export const myApiSync : MyApiSync = function (paramA : boolean) : MyApiResult {
// 返回数据,根据插件功能获取实际的返回值
const res : MyApiResult = {
fieldA: 85,
fieldB: paramA,
fieldC: 'some message'
};
return res;
// 返回数据,根据插件功能获取实际的返回值
const res : MyApiResult = {
fieldA: 85,
fieldB: paramA,
fieldC: 'some message'
};
return res;
}
/**
* 更多插件开发的信息详见:https://uniapp.dcloud.net.cn/plugin/uts-plugin.html
*/
*/
@@ -14,6 +14,7 @@ export const getBatteryInfo : GetBatteryInfo = function (options : GetBatteryInf
const context = UTSAndroid.getAppContext();
if (context != null) {
const manager = context.getSystemService(
Context.BATTERY_SERVICE
@@ -21,7 +22,7 @@ export const getBatteryInfo : GetBatteryInfo = function (options : GetBatteryInf
const level = manager.getIntProperty(
BatteryManager.BATTERY_PROPERTY_CAPACITY
);
console.log('level', level);
let ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
let batteryStatus = context.registerReceiver(null, ifilter);
let status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
@@ -0,0 +1,11 @@
## 1.0.52025-09-03
新增分贝值
## 1.0.42025-08-30
加大增益值范围
## 1.0.32025-08-29
增益
## 1.0.22025-08-29
添加新属性:增益
## 1.0.02025-08-02
# yao-RecordFrame
web audio api录音
@@ -0,0 +1,339 @@
<template>
<view
:options="options"
:change:options="record.startRecord"
:status="status"
:change:status="record.onStop"
>
</view>
</template>
<script setup>
import { ref } from 'vue';
const options = ref(null);
const status = ref(null);
// 对外暴露方法(替代 export default 的 methods
defineExpose({
start: startRecord,
stop: stopRecord
});
/**
* 启动录音
* @param {Object} option - 录音配置
* @param {Function} option.onFrameRecorded - 帧数据回调(高频)
* @param {Function} option.onDecibels - 分贝回调(高频)
* @param {number} option.sampleRate - 采样率
* @param {number} option.frameSize - 帧大小
* @param {number} option.gain - 增益
*/
function startRecord(option) {
// 校验回调函数
if (option.onFrameRecorded && typeof option.onFrameRecorded !== 'function') {
console.error('onFrameRecorded 必须是函数');
option.onFrameRecorded = null;
}
if (option.onDecibels && typeof option.onDecibels !== 'function') {
console.error('onDecibels 必须是函数');
option.onDecibels = null;
}
options.value = option;
status.value = 'start';
}
// 停止录音
function stopRecord() {
status.value = 'stop';
options.value = null;
}
// base64 转 Uint8Array(备用,若业务需要)
function base64ToUint8Array(base64) {
const binaryString = atob(base64.split(',')[1] || base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// 提示弹窗
function toShowToast() {
// uniShowToast({
// title: '发生错误,请检查是否有麦克风权限',
// icon: 'none'
// });
stopRecord();
}
// 录音停止回调(低频,转发 $emit)
function recordedChunks(base64) {
// Vue3 触发自定义事件(替代 this.$emit
emit('onStop', base64);
}
// Vue3 声明自定义事件(替代 props 中的 emits
const emit = defineEmits(['onStop']);
</script>
<script module="record" lang="renderjs">
// 保存需要关闭的引用
let mediaStream;
let audioContext;
let processor;
// 全局变量存储录音数据
let recordedChunks = [];
let decibelHistory = [];
// 分贝计算相关配置
const DB_CONFIG = {
minDecibels: -80,
maxDecibels: -30,
smoothingTimeConstant: 0.8
};
export default {
data() {
return {
};
},
methods: {
async startRecord(options) {
if (!options) return;
if (audioContext) return;
console.log(typeof options.onFrameRecorded);
// 缓存高频回调函数(核心优化)
this.currentCallbacks = {
onFrameRecorded: options.onFrameRecorded,
onDecibels: options.onDecibels
};
try {
// 配置参数校验
if (options.gain < 1.0) options.gain = 1.0;
if (options.gain > 20.0) options.gain = 20.0;
// Vue3 + 多端兼容:AudioContext 初始化
audioContext = new (window.AudioContext || window.webkitAudioContext)();
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
// 加载 AudioWorklet(注意路径适配 uni-app 静态资源)
await audioContext.audioWorklet.addModule('static/dist/processor.worklet.js');
// 权限检测 + 获取麦克风流
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this.$ownerInstance.callMethod('toShowToast');
return;
}
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: options.sampleRate,
channelCount: 1,
echoCancellation: true, // 可选:关闭回声消除(根据业务需求)
noiseSuppression: true // 可选:关闭降噪
}
});
// 初始化音频节点
const source = audioContext.createMediaStreamSource(mediaStream);
processor = new AudioWorkletNode(audioContext, 'processor-worklet');
// 初始化处理器参数
processor.port.postMessage({
type: 'init',
data: {
frameSize: options.frameSize,
fromSampleRate: 48000,
toSampleRate: options.sampleRate,
arrayBufferType: 'short16',
gain: options.gain
}
});
// 接收音频帧数据(核心:直接调用回调)
processor.port.onmessage = (t) => {
const data = t.data;
const frameBuffer = data.frameBuffer;
const isLastFrame = data.isLastFrame;
// 1. 计算分贝并触发高频回调
if (frameBuffer && frameBuffer.byteLength > 0) {
const decibels = this.calculateDecibels(frameBuffer);
this.onDecibelsCalculated(decibels); // 触发分贝回调
}
// 2. 处理帧数据并触发高频回调
if (frameBuffer && frameBuffer.byteLength) {
const frameSize = options.frameSize;
for (let offset = 0; offset < frameBuffer.byteLength; offset += frameSize) {
const sliceBuffer = frameBuffer.slice(offset, offset + frameSize);
const frameData = {
isLastFrame: isLastFrame && offset + frameSize >= frameBuffer.byteLength,
frameBuffer: this.toBase64(sliceBuffer),
frameBufferUint8: new Uint8Array(sliceBuffer) // 直接返回 Uint8Array,减少转码
};
this.onFrameRecorded(frameData);
// 存储录音数据
recordedChunks.push(sliceBuffer);
}
} else {
this.onFrameRecorded(t.data);
}
};
// 连接音频节点
source.connect(processor);
processor.connect(audioContext.destination);
} catch (err) {
console.error('录音初始化失败:', err);
this.$ownerInstance.callMethod('toShowToast');
}
},
// 分贝计算(优化后逻辑)
calculateDecibels(frameBuffer) {
try {
const samples = new Int16Array(frameBuffer);
let sum = 0;
for (let i = 0; i < samples.length; i++) {
const value = samples[i] / 32768; // 归一化到 [-1, 1]
sum += value * value;
}
const rms = Math.sqrt(sum / samples.length);
if (rms < 0.00001) return DB_CONFIG.minDecibels;
let db = 20 * Math.log10(rms);
// 平滑处理
if (decibelHistory.length > 0) {
const lastDb = decibelHistory[decibelHistory.length - 1];
db = lastDb * DB_CONFIG.smoothingTimeConstant + db * (1 - DB_CONFIG.smoothingTimeConstant);
}
// 限制范围 + 维护历史
db = Math.max(DB_CONFIG.minDecibels, Math.min(DB_CONFIG.maxDecibels, db));
decibelHistory.push(db);
if (decibelHistory.length > 100) decibelHistory.shift();
return db;
} catch (e) {
console.error('分贝计算错误:', e);
return DB_CONFIG.minDecibels;
}
},
// Base64 转换(增加空值保护)
toBase64(buffer) {
if (!buffer || buffer.byteLength === 0) return '';
let binary = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
},
// 停止录音逻辑
async onStop(value) {
if (value !== 'stop') return;
this.onFrameRecorded({
isLastFrame: true,
frameBuffer: ''
});
this.onRecordedChunks(recordedChunks);
// 清理资源(Vue3 内存管理重点)
recordedChunks = [];
decibelHistory = [];
this.currentCallbacks = { onFrameRecorded: null, onDecibels: null };
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
if (processor) {
processor.disconnect();
processor.port.close(); // 关闭消息端口
processor = null;
}
if (audioContext) {
await audioContext.close().catch(err => console.error('关闭 AudioContext 失败:', err));
audioContext = null;
}
},
// 合并音频 Buffer
mergeAudioBuffers(buffers) {
const totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
buffers.forEach(buffer => {
result.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
});
return result.buffer;
},
// 创建 WAV 文件(修复 Vue3 端的字节序问题)
createWavBlob(pcmData, numChannels, sampleRate) {
const bytesPerSample = 2;
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const bufferLength = pcmData.byteLength;
const totalLength = 44 + bufferLength;
const buffer = new ArrayBuffer(totalLength);
const view = new DataView(buffer);
// 写入 WAV 头
this.writeString(view, 0, 'RIFF');
view.setUint32(4, totalLength - 8, true);
this.writeString(view, 8, 'WAVE');
this.writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // PCM 格式
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true); // 16 位采样
this.writeString(view, 36, 'data');
view.setUint32(40, bufferLength, true);
// 写入 PCM 数据(Vue3 多端兼容)
const pcm8 = new Uint8Array(pcmData);
for (let i = 0; i < pcm8.length; i++) {
view.setUint8(44 + i, pcm8[i]);
}
return new Blob([buffer], { type: 'audio/wav' });
},
// Blob 转 Base64
blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
},
// 辅助:写入字符串到 DataView
writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
}
};
</script>
@@ -0,0 +1,359 @@
<template>
<view :options="options" :change:options="record.startRecord" :status="status" :change:status="record.onStop">
</view>
</template>
<script>
export default {
data() {
return {
options: null,
status: null,
onFrameRecorded: ()=>{},
onDecibels: ()=>{},
}
},
methods: {
start(option) {
this.options = option;
this.status = 'start';
if (option.onFrameRecorded && typeof option.onFrameRecorded === 'function') {
this.onFrameRecorded = option.onFrameRecorded
}
if (option.onDecibels && typeof option.onDecibels === 'function') {
this.onDecibels = option.onDecibels
}
},
stop() {
this.status = 'stop';
this.options = null;
},
frameRecorded({
isLastFrame,
frameBuffer
}) {
// console.log('1111',typeof this.onFrameRecorded);
this.onFrameRecorded({
isLastFrame,
frameBuffer: this.base64ToUint8Array(frameBuffer)
})
// this.$emit('onFrameRecorded', {
// isLastFrame,
// frameBuffer: this.base64ToUint8Array(frameBuffer)
// })
},
decibels(value) {
this.onDecibels(value)
// this.$emit('currentDecibels', value);
},
base64ToUint8Array(base64) {
const binaryString = atob(base64.split(',')[1] || base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
},
toShowToast() {
uni.showToast({
title: '发生错误,请检查是否有麦克风权限',
icon: 'none'
});
this.stop();
},
recordedChunks(base64) {
this.$emit('onStop', base64)
},
}
}
</script>
<script module="record" lang="renderjs">
// 保存需要关闭的引用
let mediaStream;
let audioContext;
let processor;
// 全局变量存储录音数据
let recordedChunks = [];
let decibelHistory = []; // 存储分贝历史数据用于可视化
// 分贝计算相关配置
const DB_CONFIG = {
minDecibels: -80, // 最小可测分贝
maxDecibels: -30, // 最大可测分贝
smoothingTimeConstant: 0.8 // 平滑系数,使分贝变化更平缓
};
export default {
data() {
return {
}
},
methods: {
async startRecord(options) {
if (options == null) return;
if (audioContext) return;
try {
// 配置参数
var a, i = options.sampleRate,
s = options.frameSize;
if (options.gain < 1.0) {
options.gain = 1.0;
}
if (options.gain > 20.0) {
options.gain = 20.0;
}
audioContext = new AudioContext();
// 加载并初始化AudioWorklet
await audioContext.audioWorklet.addModule('static/dist/processor.worklet.js');
//const mediaStream = new MediaStream();
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: options.sampleRate,
channelCount: 1
}
});
const source = audioContext.createMediaStreamSource(mediaStream);
processor = new AudioWorkletNode(audioContext, 'processor-worklet');
// 初始化处理器
processor.port.postMessage({
type: 'init',
data: {
frameSize: options.frameSize, // 样本数 (1280字节 / 2字节每样本)
fromSampleRate: 48000, // 输入采样率
toSampleRate: options.sampleRate, // 输出采样率 (1/3)
arrayBufferType: 'short16',
gain: options.gain
}
});
// 接收40ms间隔的音频数据
processor.port.onmessage = (t) => {
var r = t.data,
o = r.frameBuffer,
n = r.isLastFrame;
// 计算当前帧的分贝值
if (o && o.byteLength > 0) {
const decibels = this.calculateDecibels(o);
this.onDecibelsCalculated(decibels); // 触发分贝回调
// 存储历史数据,限制长度
decibelHistory.push(decibels);
if (decibelHistory.length > 100) {
decibelHistory.shift();
}
}
if (null == o ? void 0 : o.byteLength)
for (var a = 0; a < o.byteLength;) {
const frameData = {
isLastFrame: n && a + s >= o.byteLength,
frameBuffer: t.data.frameBuffer.slice(a, a + s)
};
this.onFrameRecorded(frameData);
// 存储录音数据(仅在非暂停状态)
recordedChunks.push(frameData.frameBuffer);
a += s;
}
else this.onFrameRecorded(t.data);
};
source.connect(processor);
processor.connect(audioContext.destination);
} catch (err) {
this.$ownerInstance.callMethod('toShowToast');
}
},
onFrameRecorded({
isLastFrame,
frameBuffer
}) {
this.$ownerInstance.callMethod('frameRecorded', {
isLastFrame,
frameBuffer: this.toBase64(frameBuffer)
});
},
calculateDecibels(frameBuffer) {
try {
// 将帧数据转换为16位整数数组
const samples = new Int16Array(frameBuffer);
// 计算均方根(RMS)
let sum = 0;
for (let i = 0; i < samples.length; i++) {
const value = samples[i] / 32768; // 归一化到[-1, 1]范围
sum += value * value; // 平方和
}
const rms = Math.sqrt(sum / samples.length);
// 防止log(0)错误
if (rms < 0.00001) {
return DB_CONFIG.minDecibels;
}
// 转换为分贝 (20 * log10(rms))
let db = 20 * Math.log10(rms);
// 应用平滑处理
if (decibelHistory.length > 0) {
const lastDb = decibelHistory[decibelHistory.length - 1];
db = lastDb * DB_CONFIG.smoothingTimeConstant + db * (1 - DB_CONFIG.smoothingTimeConstant);
}
// 限制分贝范围
return Math.max(DB_CONFIG.minDecibels, Math.min(DB_CONFIG.maxDecibels, db));
} catch (e) {
console.error('计算分贝时出错:', e);
return DB_CONFIG.minDecibels;
}
},
onDecibelsCalculated(decibels) {
//console.log(`当前分贝: ${decibels.toFixed(1)} dB`);
this.$ownerInstance.callMethod('decibels', decibels.toFixed(1));
// 可以在这里添加分贝可视化逻辑
// 例如更新UI显示当前音量
},
toBase64(buffer) {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
},
async onRecordedChunks(chunks) {
var mergedBuffer = this.mergeAudioBuffers(chunks);
// 2. 将合并的二进制流转换为WAV格式(需要添加WAV文件头)
const wavBlob = this.createWavBlob(mergedBuffer, 1, 16000); // 单声道,44100Hz
// 3. 将WAV转为Base64(可选,若需传输)
const base64 = await this.blobToBase64(wavBlob);
this.$ownerInstance.callMethod('recordedChunks', base64)
},
onStop(value) {
if (value !== 'stop') return;
this.onFrameRecorded({
isLastFrame: true,
frameBuffer: ''
});
this.onRecordedChunks(recordedChunks);
recordedChunks = [];
if (mediaStream) {
// 停止所有媒体轨道
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
if (processor) {
// 断开音频节点连接
processor.disconnect();
processor = null;
}
if (audioContext) {
// 关闭音频上下文
audioContext.close().then(() => {
audioContext = null;
});
}
},
//合并所有buffer
mergeAudioBuffers(buffers) {
let totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
buffers.forEach(buffer => {
result.set(new Uint8Array(buffer), offset);
offset += buffer.byteLength;
});
// 验证合并后的长度是否正确
if (offset !== totalLength) console.error("合并后的长度不符!");
return result.buffer;
},
// 新增WAV封装函数(基于前序回答的createWavBlob
createWavBlob(pcmData, numChannels, sampleRate) {
const bytesPerSample = 2; // 16-bit PCM
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const bufferLength = pcmData.byteLength;
const totalLength = 44 + bufferLength; // WAV头(44字节) + 音频数据
const buffer = new ArrayBuffer(totalLength);
const view = new DataView(buffer);
// 写入WAV文件头(RIFF、fmt、data区块)
this.writeString(view, 0, 'RIFF');
view.setUint32(4, totalLength - 8, true);
this.writeString(view, 8, 'WAVE');
this.writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // fmt区块大小
view.setUint16(20, 1, true); // PCM格式
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true); // 16位采样
this.writeString(view, 36, 'data');
view.setUint32(40, bufferLength, true);
// 写入音频数据(假设pcmData是Uint8Array,需转换为16位PCM
const pcm16 = new Int16Array(pcmData);
for (let i = 0; i < pcm16.length; i++) {
view.setInt16(44 + i * 2, pcm16[i], true);
}
return new Blob([buffer], {
type: 'audio/wav'
});
},
// 辅助函数:Blob转Base64
blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
},
// 辅助函数:字符串写入DataView
writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,300 @@
! function() {
"use strict";
function t(t, e) {
for (var r = 0; r < e.length; r++) {
var n = e[r];
n.enumerable = n.enumerable || !1, n.configurable = !0, "value" in n && (n.writable = !0), Object
.defineProperty(t, (i = n.key, o = void 0, "symbol" == typeof(o = function(t, e) {
if ("object" != typeof t || null === t) return t;
var r = t[Symbol.toPrimitive];
if (void 0 !== r) {
var n = r.call(t, e || "default");
if ("object" != typeof n) return n;
throw new TypeError("@@toPrimitive must return a primitive value.")
}
return ("string" === e ? String : Number)(t)
}(i, "string")) ? o : String(o)), n)
}
var i, o
}
function e(t) {
return e = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function(t) {
return t.__proto__ || Object.getPrototypeOf(t)
}, e(t)
}
function r(t, e) {
return r = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function(t, e) {
return t.__proto__ = e, t
}, r(t, e)
}
function n() {
if ("undefined" == typeof Reflect || !Reflect.construct) return !1;
if (Reflect.construct.sham) return !1;
if ("function" == typeof Proxy) return !0;
try {
return Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], (function() {}))), !0
} catch (t) {
return !1
}
}
function i(t, e, o) {
return i = n() ? Reflect.construct.bind() : function(t, e, n) {
var i = [null];
i.push.apply(i, e);
var o = new(Function.bind.apply(t, i));
return n && r(o, n.prototype), o
}, i.apply(null, arguments)
}
function o(t) {
var n = "function" == typeof Map ? new Map : void 0;
return o = function(t) {
if (null === t || (o = t, -1 === Function.toString.call(o).indexOf("[native code]"))) return t;
var o;
if ("function" != typeof t) throw new TypeError("Super expression must either be null or a function");
if (void 0 !== n) {
if (n.has(t)) return n.get(t);
n.set(t, a)
}
function a() {
return i(t, arguments, e(this).constructor)
}
return a.prototype = Object.create(t.prototype, {
constructor: {
value: a,
enumerable: !1,
writable: !0,
configurable: !0
}
}), r(a, t)
}, o(t)
}
function a(t) {
if (void 0 === t) throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
return t
}
function s(t) {
var r = n();
return function() {
var n, i = e(t);
if (r) {
var o = e(this).constructor;
n = Reflect.construct(i, arguments, o)
} else n = i.apply(this, arguments);
return function(t, e) {
if (e && ("object" == typeof e || "function" == typeof e)) return e;
if (void 0 !== e) throw new TypeError(
"Derived constructors may only return object or undefined");
return a(t)
}(this, n)
}
}
function f(t) {
return function(t) {
if (Array.isArray(t)) return u(t)
}(t) || function(t) {
if ("undefined" != typeof Symbol && null != t[Symbol.iterator] || null != t["@@iterator"]) return Array
.from(t)
}(t) || function(t, e) {
if (!t) return;
if ("string" == typeof t) return u(t, e);
var r = Object.prototype.toString.call(t).slice(8, -1);
"Object" === r && t.constructor && (r = t.constructor.name);
if ("Map" === r || "Set" === r) return Array.from(t);
if ("Arguments" === r || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)) return u(t, e)
}(t) || function() {
throw new TypeError(
"Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."
)
}()
}
function u(t, e) {
(null == e || e > t.length) && (e = t.length);
for (var r = 0, n = new Array(e); r < e; r++) n[r] = t[r];
return n
}
function l(t, e, r, n) {
this.fromSampleRate = t, this.toSampleRate = e, this.channels = 0 | r, this.noReturn = !!n, this.initialize()
}
l.prototype.initialize = function() {
if (!(this.fromSampleRate > 0 && this.toSampleRate > 0 && this.channels > 0)) throw new Error(
"Invalid settings specified for the resampler.");
this.fromSampleRate == this.toSampleRate ? (this.resampler = this.bypassResampler, this.ratioWeight = 1) : (
this.fromSampleRate < this.toSampleRate ? (this.lastWeight = 1, this.resampler = this
.compileLinearInterpolation) : (this.tailExists = !1, this.lastWeight = 0, this.resampler = this
.compileMultiTap), this.ratioWeight = this.fromSampleRate / this.toSampleRate)
}, l.prototype.compileLinearInterpolation = function(t) {
var e = t.length;
this.initializeBuffers(e);
var r, n, i = this.outputBufferSize,
o = this.ratioWeight,
a = this.lastWeight,
s = 0,
f = 0,
u = 0,
l = this.outputBuffer;
if (e % this.channels == 0) {
if (e > 0) {
for (; a < 1; a += o)
for (s = 1 - (f = a % 1), r = 0; r < this.channels; ++r) l[u++] = this.lastOutput[r] * s + t[
r] * f;
for (a--, e -= this.channels, n = Math.floor(a) * this.channels; u < i && n < e;) {
for (s = 1 - (f = a % 1), r = 0; r < this.channels; ++r) l[u++] = t[n + r] * s + t[n + this
.channels + r] * f;
a += o, n = Math.floor(a) * this.channels
}
for (r = 0; r < this.channels; ++r) this.lastOutput[r] = t[n++];
return this.lastWeight = a % 1, this.bufferSlice(u)
}
return this.noReturn ? 0 : []
}
throw new Error("Buffer was of incorrect sample length.")
}, l.prototype.compileMultiTap = function(t) {
var e = [],
r = t.length;
this.initializeBuffers(r);
var n = this.outputBufferSize;
if (r % this.channels == 0) {
if (r > 0) {
for (var i = this.ratioWeight, o = 0, a = 0; a < this.channels; ++a) e[a] = 0;
var s = 0,
f = 0,
u = !this.tailExists;
this.tailExists = !1;
var l = this.outputBuffer,
h = 0,
c = 0;
do {
if (u)
for (o = i, a = 0; a < this.channels; ++a) e[a] = 0;
else {
for (o = this.lastWeight, a = 0; a < this.channels; ++a) e[a] += this.lastOutput[a];
u = !0
}
for (; o > 0 && s < r;) {
if (!(o >= (f = 1 + s - c))) {
for (a = 0; a < this.channels; ++a) e[a] += t[s + a] * o;
c += o, o = 0;
break
}
for (a = 0; a < this.channels; ++a) e[a] += t[s++] * f;
c = s, o -= f
}
if (0 != o) {
for (this.lastWeight = o, a = 0; a < this.channels; ++a) this.lastOutput[a] = e[a];
this.tailExists = !0;
break
}
for (a = 0; a < this.channels; ++a) l[h++] = e[a] / i
} while (s < r && h < n);
return this.bufferSlice(h)
}
return this.noReturn ? 0 : []
}
throw new Error("Buffer was of incorrect sample length.")
}, l.prototype.bypassResampler = function(t) {
return this.noReturn ? (this.outputBuffer = t, t.length) : t
}, l.prototype.bufferSlice = function(t) {
if (this.noReturn) return t;
try {
return this.outputBuffer.subarray(0, t)
} catch (e) {
try {
return this.outputBuffer.length = t, this.outputBuffer
} catch (e) {
return this.outputBuffer.slice(0, t)
}
}
}, l.prototype.initializeBuffers = function(t) {
this.outputBufferSize = Math.ceil(t * this.toSampleRate / this.fromSampleRate);
try {
this.outputBuffer = new Float32Array(this.outputBufferSize), this.lastOutput = new Float32Array(this
.channels)
} catch (t) {
this.outputBuffer = [], this.lastOutput = []
}
};
var h = function(e) {
! function(t, e) {
if ("function" != typeof e && null !== e) throw new TypeError(
"Super expression must either be null or a function");
t.prototype = Object.create(e && e.prototype, {
constructor: {
value: t,
writable: !0,
configurable: !0
}
}), Object.defineProperty(t, "prototype", {
writable: !1
}), e && r(t, e)
}(h, e);
var n, i, o, u = s(h);
function h() {
var t;
! function(t, e) {
if (!(t instanceof e)) throw new TypeError("Cannot call a class as a function")
}(this, h);
var e = a(t = u.call(this));
return t.port.onmessage = function(t) {
var r = t.data,
n = r.type,
i = r.data;
if (console.log("type", n), "init" === n) {
var o = i.frameSize,
a = i.toSampleRate,
s = i.arrayBufferType,
f = i.fromSampleRate;
return e.frameSize = o * Math.floor(f / a), e.resampler = new l(f, a, 1), e
.frameBuffer = [], void(e.arrayBufferType = s),e.gain = i.gain || 1.0;
}
"stop" === n && (e.port.postMessage({
frameBuffer: e.transData(e.frameBuffer),
isLastFrame: !0
}), e.frameBuffer = [])
}, t
}
return n = h, (i = [{
key: "process",
value: function(t) {
var e, r = t[0][0];
return this.frameSize ? ((e = this.frameBuffer).push.apply(e, f(r)), this
.frameBuffer.length >= this.frameSize && (this.port.postMessage({
frameBuffer: this.transData(this.frameBuffer),
isLastFrame: !1
}), this.frameBuffer = []), !0) : (r && this.port.postMessage({
frameBuffer: this.transData(r),
isLastFrame: !1
}), !0)
}
}, {
key: "transData",
value: function(t) {
const gain = this.gain; // 增益系数,可根据实际需求修改
t = t.map(sample => sample * gain); // 对每个样本应用增益
return "short16" === this.arrayBufferType && (t = function(t) {
for (var e = new ArrayBuffer(2 * t.length), r = new DataView(e), n = 0,
i = 0; i < t.length; i += 1, n += 2) {
var o = Math.max(-1, Math.min(1, t[i]));
r.setInt16(n, o < 0 ? 32768 * o : 32767 * o, !0)
}
return r.buffer
}(t = this.resampler.resampler(t))), t
}
}]) && t(n.prototype, i), o && t(n, o), Object.defineProperty(n, "prototype", {
writable: !1
}), h
}(o(AudioWorkletProcessor));
registerProcessor("processor-worklet", h)
}();
@@ -0,0 +1,100 @@
{
"id": "yao-RecordFrame",
"displayName": "web audio api录音+实时帧回调 支持 ios Android h5",
"version": "1.0.5",
"description": "web audio api录音、实时帧回调、分贝值",
"keywords": [
"ios",
"Android",
"h5",
"实时帧回调",
"分贝"
],
"repository": "",
"engines": {
"uni-app": "^4.07",
"uni-app-x": ""
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "3371387322"
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "需要麦克风权限"
},
"npmurl": "",
"darkmode": "x",
"i18n": "x",
"widescreen": "x"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"vue": "√",
"nvue": "-",
"android": "√",
"ios": "√",
"harmony": "x"
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}
@@ -0,0 +1,78 @@
# yao-RecordFrame
##配置
需要将模块下uni_modules/yao-RecordFrame的dist复制到static目录下面
或者
将uni_modules/yao-RecordFrame/dist目录的配置文件放到static/dist目录下面
###说明
插件只适用于接实时语音识别的模型,不适合录音上传
### 示例代码
```javascript
<template>
<view class="content">
<view class="start-record" @click="onStartRecord">开始录音</view>
<view class="stop-record" @click="onStopRecord">停止录音</view>
<view class="frame">
<view>实时回调是否是最后一帧{{isLastFrame}}</view>
<view class="frameBuffer">{{frameBuffer}}</view>
</view>
<yao-RecordFrame
ref="recordFrame"
@onFrameRecorded="frameRecorded"
@currentDecibels="onCurrentDecibels"
@onStop="stopIt"></yao-RecordFrame>
</view>
</template>
<script>
export default {
data() {
return {
frameBuffer:'',//实时帧的frameBuffer值
isLastFrame:false,//是否是最后一帧
}
},
onLoad() {
},
methods: {
onStartRecord(){
//开启录音(仅支持两种参数)
this.$refs.recordFrame.start({
sampleRate:16000,
frameSize:1024,
gain:1.0 //增益值,数字越高音频声音越大 1.0~20.0
})
},
onStopRecord(){
//停止录音
this.$refs.recordFrame.stop();
},
//停止录音
stopIt(base64){
//base64音频只能在浏览器播放
//也可以base64音频转成文件音频可app播放
console.log(base64);
},
frameRecorded({isLastFrame,frameBuffer}){
console.log(isLastFrame,frameBuffer);
if(!isLastFrame){
this.frameBuffer=frameBuffer;
}
this.isLastFrame=isLastFrame?'true':'false';
},
onCurrentDecibels(decibels){
//当前分贝值 最小可测分贝(-80) 最大可测分贝(-30)
console.log("当前分贝:" + decibels)
}
}
}
</script>
```