JLBA202505130001_关于吉林银行新建AI智能培训系统的需求_开发语音采集uts插件

This commit is contained in:
田岩
2026-02-09 14:06:53 +08:00
parent ff0f104e37
commit da77f3fd40
39 changed files with 1225 additions and 662 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ ENV = 'development'
#VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001'
#VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001'
VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001'
# VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7002'
# VITE_APP_BASE_API_Url = 'http://192.168.247.200'
@@ -16,7 +16,7 @@ ENV = 'development'
# dev
# DEV
VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001'
#VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001'
# sit
#VITE_APP_BASE_API_Url = 'http://25.18.122.91:9786'
@@ -0,0 +1,242 @@
<template>
<view class="main-div">
<view class="main-input-div">
<!-- 多行文本 -->
<view class="input-div">
<uv-textarea
class="textarea"
:class="{'margin-bottom-12':isShow}"
v-model="value"
border="none"
placeholder="发条评论,和大家一起讨论"
:customStyle="customStyle"
autoHeight
:adjustPosition="false"
fixed
@focus="focus"
@blur="blur"
></uv-textarea>
</view>
<!-- 下方的图片展示框 -->
<view class="imgs-div" v-if="isShow">
<image class="upload-img" v-for="imgItem in uploadImgs" :src="imgItem" mode=""></image>
</view>
<!-- 工具框 -->
<view class="setting-div" v-if="isShow"></view>
</view>
<view class="tool-div" v-if="isShow">
<view class="tool-icon tool-icon-img" @click="upload">
<image class="img" :src="commentInput3" mode="aspectFit"></image>
</view>
<view class="tool-icon tool-icon-emoji">
<image class="img" :src="commentInput2" mode="aspectFit"></image>
</view>
<view class="fl1"></view>
<view class="tool-send">发送</view>
</view>
<!-- 下方主要表情输入框 -->
<view class="bottom-main-input-div">
<view class="expression-tool-div">
<view class="tool-icon tool-icon-img checked_tool" @click="upload">
<image class="img" :src="commentInput1" mode="aspectFit"></image>
</view>
<view class="tool-icon tool-icon-emoji checked_tool">
<image class="img" :src="commentInput4" mode="aspectFit"></image>
</view>
<view class="fl1"></view>
</view>
<view class="emoji-list">
<view class="emoji-item" v-for="emojiItem in emojiList">
{{emojiItem}}
</view>
</view>
</view>
</view>
</template>
<script setup>
import commentInput1 from '@/static/images/courseDetail/comment-input-1.png'
import commentInput2 from '@/static/images/courseDetail/comment-input-2.png'
import commentInput3 from '@/static/images/courseDetail/comment-input-3.png'
import commentInput4 from '@/static/images/courseDetail/comment-input-4.png'
import { reactive, ref } from 'vue';
import { emojiList } from '@/emoji';
const customStyle = {
backgroundColor: 'rgb(227, 230, 237)',
padding: '18rpx'
};
const uploadImgs = ref([
// '/src/static/images/test/e7dd89a71493fcc6281700a4864e24cb.jpg'
])
const isShow = ref(false);
const popHeight = ref('0px')
const value = ref('');
const click_input = () => {
if(!isShow.value) isShow.value = true
}
const focus = (event) => {
popHeight.value = '278px'
isShow.value = true
console.log('event', event.detail.height);
}
const blur = (event) => {
console.log('失去焦点不一定关闭');
// popHeight.value = '0px'
isShow.value = true
}
const fileList = reactive([]);
// 方法
const upload = (limit = 4) => {
uni.chooseImage({
count: limit,
sizeType: ['original', 'compressed'],
sourceType: ['camera', 'album'],
success: async ({
tempFilePaths
}) => {
const valid = tempFilePaths.every(url => {
const extension = url.slice(lastDotIndex + 1).split('?')[0].toLowerCase()
return ['png', 'jpg'].includes(extension)
})
if (!valid) return common.msg('您选择的图片格式有误。')
const data = {
bizScen: 'S3005'
}
const upload_map = tempFilePaths.map((file) => uploadFile(file, data))
Promise.all(upload_map).then(res => {
fileList.push(...res.splice(0, limit - fileList.length))
}).catch((err) => {
common.msg(err)
})
}
})
}
</script>
<style scoped lang="scss">
.emoji-list{
display: flex;
flex-wrap: wrap;
margin: 0 auto;
background-color: red;
justify-content: center;
.emoji-item{
font-size: 44rpx;
padding: 14rpx;
}
}
// 表情输入框
.expression-tool-div{
padding: 20rpx 10rpx;
display: flex;
align-items: center;
.tool-icon{
display: flex;
align-items: center;
margin-right: 30rpx;
padding: 14rpx 28rpx;
border-radius: 60rpx;
margin: 0rpx 10rpx;
.img {
width: 50rpx;
height: 50rpx;
}
}
.checked_tool{
background-color: rgb(243, 243, 243);
}
}
.bottom-main-input-div{
width: 100%;
height: v-bind(popHeight);
transition: height 0.3s ease 0s;
}
.none {
display: none;
}
.margin-bottom-12{
margin-bottom: 12rpx;
}
.tool-div {
padding: 10rpx calc(18rpx + 20rpx);
display: flex;
align-items: center;
.tool-icon{
// padding: 14rpx 34rpx;
// border-radius: 60rpx;
// margin: 0rpx 10rpx;
}
.tool-icon-img {
display: flex;
align-items: center;
margin-right: 30rpx;
.img {
width: 50rpx;
height: 50rpx;
}
}
.tool-icon-emoji {
display: flex;
align-items: center;
.img {
width: 50rpx;
height: 50rpx;
}
}
.tool-send {
border-radius: 50rpx;
width: 120rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
background-color: rgb(254, 44, 85);
font-size: 30rpx;
color: #fff;
margin-right: 20rpx;
}
}
.main-div {
position: fixed;
bottom: 0;
width: 100%;
background-color: #ffffff;
z-index: 1000;
border-top: 2rpx solid rgb(220, 220, 220);
box-shadow: 0 -2rpx 20rpx rgb(220, 220, 220);
}
.main-input-div {
width: calc(100% - 40rpx);
margin: 20rpx;background-color: red;
display: flex;
flex-direction: column;
border-radius: 40rpx;
overflow: hidden;
background-color: rgb(227, 230, 237);
.input-div {
width: 100%;
padding: 0;
}
.imgs-div {
display: flex;
padding: 0 18rpx;
.upload-img {
margin: 0rpx 20rpx 20rpx 0;
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
}
}
.setting-div {
width: 100%;
height: 100rpx;
padding: 0 18rpx;
}
}
.tool-div {
border-bottom: 4rpx solid rgb(206, 206, 206);
}
</style>
+522
View File
@@ -0,0 +1,522 @@
<template>
<view class="talk-btns">
<view class="touch-btn" @click="disabled_click">
<!-- @touchend="stopRecord(false)" -->
<uv-button
class="touch-btn"
@touchstart="startRecord"
@touchmove="handleMove"
@touchend="loosenButton"
v-if="!keyboardIsShow"
:customStyle="{ height: '92rpx' }"
>
按住说话
</uv-button>
<uv-input
v-if="keyboardIsShow"
class="input-box"
ref="inputRef"
v-model="inputText"
:maxlength="props.textValueLength"
:adjustPosition="true"
placeholderStyle="color:#999999;font-size:26rpx"
placeholder="请输入文字"
:disabled="props.isDisabled"
></uv-input>
</view>
<view class="talk-btn-box">
<image
class="talk-btn-icon"
v-show="!keyboardIsShow"
src="@/static/images/course/jianpan.png"
mode=""
@click="changeBtn"
></image>
<image
class="talk-btn-icon"
v-show="keyboardIsShow && !inputText"
src="@/static/images/course/record.png"
mode=""
@click="changeBtn"
></image>
<image
class="talk-btn-icon"
v-show="keyboardIsShow && !!inputText"
src="@/static/images/course/send.png"
mode=""
@click.stop="showKeyboard"
></image>
</view>
<uni-popup ref="popupRef" class="popup">
<view class="overlay-box">
<view class="circle-bg-box">
<view class="tips-box">松开发送</view>
</view>
<view class="btns-box">
<image
class="btn-box btn-box-1"
:src="isOutStatus == '02' ? cancelActiveBtn : cancelBtn"
mode="heightFix"
@click="closePopup()"
></image>
<image
class="btn-box btn-box-2"
:src="isOutStatus == '03' ? translateActiveBtn : translateBtn"
mode="heightFix"
></image>
</view>
<view class="talk-dialog" :class="{ 'is-out': isOutStatus === '02', 'is-translate': isOutStatus === '03' }">
<image src="@/static/images/course/voice-white.gif" mode="" class="gif-box"></image>
<uv-textarea
ref="inputRef"
v-model="allText"
:maxlength="255"
@click.stop
customStyle="background: #ffffff00; padding-bottom: 50rpx; color: #fff; caret-color: #fff"
text-style="color: #fff"
border="none"
placeholderStyle="color:#999999;font-size:26rpx;"
placeholder=""
></uv-textarea>
<!-- <view class="text-div" v-if="isOutStatus === '03'">
{{ allText }}{{ tempText }}
</view> -->
</view>
<view class="fl1"></view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import cancelBtn from '@/static/images/course/cancel.png';
import cancelActiveBtn from '@/static/images/course/cancel-active.png';
import translateBtn from '@/static/images/course/translate.png';
import translateActiveBtn from '@/static/images/course/translate-active.png';
import { ref, computed, onMounted, nextTick, watch, reactive } from 'vue';
import common from '@/common/common';
import { onHide } from '@dcloudio/uni-app';
import permission from '@/common/permission.js';
import { getPhoneEnvBool } from '@/common/common.js';
import { initRecord } from '@/composables/useExamSubject.js';
import { getToken } from '@/common/common';
import { startAudioRecord, stopAudioRecord } from '@/uni_modules/ty-recording';
import { get_base_url } from '@/api/request.js';
import { ProtocolCodec, MessageType, ControlCommand,ControlCode } from '@/pages/sparring/js/ProtocolCodec';
// const url = '/trapractice/voiceCallSocket/voiceCall'
// const url = '/traapp/voiceTransSocket/open';
const url = '/traapp/voiceAiModelSocket/open';
const _baseUrl = get_base_url(url).split('http').join('ws');
const emit = defineEmits(['submit']);
const ws = ref(null);
const props = defineProps({
isDisabled: {
default: false,
type: Boolean
},
isLoading: {
default: false,
type: Boolean
},
loadingText: {
default: '发送回答中,请稍后再试!',
type: String
},
disabledText: {
default: '请稍后再试!',
type: String
},
canAnswer: {
default: true,
type: Boolean
},
canAnswerText: {
default: '当前状态不能发送内容!',
type: String
},
textValueLength: {
type: Number,
default: 500
},
minRecordDuration: {
// 最小录音间隔
type: Number,
default: 1000
}
});
// 记录 按下移动位置
const touchPos = reactive({ x: 0, y: 0 });
const windowInfo = uni.getWindowInfo();
// 安全区域信息
const safeArea = windowInfo.safeArea;
console.log('safeArea', safeArea);
// 计算是否超出按下盒子位置01代表下方盒子,02代表左上03代表右上
const isOutStatus = computed(() => {
const btnHeight = (windowInfo.screenWidth / 750) * 234;
if (touchPos.y < safeArea.bottom - btnHeight) {
if (touchPos.x < safeArea.right / 2) return '02';
else return '03';
} else {
return '01';
}
});
// 监听按下移动
const handleMove = (obj) => {
const changedTouche = obj.changedTouches[0];
touchPos.x = changedTouche.pageX;
touchPos.y = changedTouche.pageY;
};
const allText = ref('');
const tempText = ref('');
// 键盘还是语音
const keyboardIsShow = ref(false);
const popupRef = ref(null);
const inputText = ref('');
let startTime = 0;
const startTimer = ref(null); // 延迟启动的定时器ID
const startFlag = ref(1);
const startRecord = async (obj) => {
const changedTouche = obj.touches[0];
touchPos.x = changedTouche.clientX;
touchPos.y = changedTouche.clientY;
const wsUrl = _baseUrl + url + '?summary=' + getToken();
ws.value = uni.connectSocket({
url: wsUrl,
fail: () => {},
success: () => {}
});
ws.value.onOpen((res) => {
console.log('ws已连接');
popupRef.value.open();
startAudioRecord({
onFrame:(pcmData) => {
// pcmData 是ByteArray类型的原始音频数据
console.log('采集到音频数据长度1:', pcmData.length);
ws.value.send({
data: ProtocolCodec.pack(MessageType.AUDIO_DATA, pcmData)
});
// 可在此处将PCM转Base64/编码为MP3等,或直接传输
},
onStop:(pcmData) => {
console.log('关闭');
},
onError:(err) => {
console.log('err', err.errMsg);
console.log('err', err.errCode);
},
});
});
ws.value.onError((err) => {
console.log('err', err);
});
ws.value.onClose((err) => {
console.log('onClose', err);
});
ws.value.onMessage((res) => {
const { msgType, body } = ProtocolCodec.unpack(res.data);
console.log('来消息了', msgType, body);
if (msgType === MessageType.TEXT_MESSAGE) {
tempText.value = '';
allText.value = allText.value + body;
} else if (msgType === MessageType.TIP_MESSAGE) {
tempText.value = body;
}
});
};
// 关掉遮罩层
const closePopup = () => {
nextTick(() => {
allText.value = '';
tempText.value = '';
popupRef.value.close();
});
ws.value.close({
code: 1000,
reason: '挂断',
success: () => {
console.log('挂断success');
},
fail: () => {
console.log('挂断fail');
},
complete: () => {
console.log('挂断complete');
}
});
};
// 松开按钮
const loosenButton = () => {
// 判断一下位置,03的时候是转文字不能关闭
if (isOutStatus.value === '03') {
// 03转文字
console.log('已发送', '03转文字');
ws.value.send({
data: ProtocolCodec.pack(MessageType.CONTROL_CMD, {type:ControlCode.FRNOT_TO_SERVER_OVER})
});
} else {
closePopup();
}
stopRecord();
};
// 结束录音
const stopRecord = (status = false) => {
console.log('结束录音stopRecord', status);
// 判断一下位置
if (isOutStatus.value === '01') {
// 01发送
} else if (isOutStatus.value === '02') {
// 02取消
} else if (isOutStatus.value === '03') {
// 03转文字
}
stopAudioRecord();
};
onMounted(async () => {});
onHide(() => {
// closePopup();
console.log('onHide');
});
const activeNum = ref(1);
const clearinputText = (text = '') => {
const old_text = inputText.value;
inputText.value = text;
return old_text;
};
// 发送文本
const showKeyboard = () => {
if (keyboardIsShow.value) {
const inputTextTram = inputText.value.trim();
if (inputTextTram) {
emit('submit', 'text', inputTextTram);
} else {
common.msg('不能发送空白消息!');
}
} else {
keyboardIsShow.value = !keyboardIsShow.value;
}
};
// 切换键盘语音
const changeBtn = () => {
if (props.isDisabled) return;
keyboardIsShow.value = !keyboardIsShow.value;
inputText.value = '';
};
// 禁用时点击给的提示
const disabled_click = () => {
console.log('禁用时点击给的提示');
if (props.isDisabled) common.msg(props.disabledText);
};
// 外面调用放弃这次
const give_up = () => {
console.log('调用结束语音回答');
stopRecord(false);
};
defineExpose({ clearinputText, give_up });
</script>
<style lang="scss" scoped>
.talk-btns {
height: 150rpx;
background-color: #fff;
padding: 25rpx 30rpx 0;
position: relative;
.touch-btn {
height: 92rpx;
box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.06);
text-align: center;
line-height: 92rpx;
color: #000;
font-weight: 400;
position: relative;
.input-box {
width: 100%;
height: 92rpx;
line-height: 92rpx;
text-align: left;
box-sizing: border-box;
padding: 0 40rpx 0 40rpx;
}
}
.talk-btn-box {
position: absolute;
height: 100%;
width: 120rpx;
right: 0;
top: 0;
.talk-btn-icon {
width: 40rpx;
height: 40rpx;
position: absolute;
right: 60rpx;
top: 50%;
margin-top: -20rpx;
}
}
}
.overlay-box {
display: flex;
flex-direction: column-reverse;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
.circle-bg-box {
height: 234rpx;
background: url(@/static/images/course/talking.png) no-repeat;
background-size: cover;
position: relative;
.icon-box {
position: absolute;
left: 50%;
top: 50%;
margin-left: -17rpx;
margin-top: -24rpx;
width: 34rpx;
height: 48rpx;
image {
width: 100%;
height: 100%;
}
}
}
.tips-box {
height: 40rpx;
line-height: 40rpx;
color: #c0c0c0;
text-align: center;
margin: 36rpx 0 12rpx 0;
font-weight: 600;
font-size: 34rpx;
color: #000000;
line-height: 46px;
}
.btns-box {
height: 240rpx;
width: 100%;
font-size: 28rpx;
display: flex;
z-index: 1200;
justify-content: space-around;
position: relative;
.btn-box {
position: absolute;
height: 164rpx;
transform: scale(1.2);
bottom: -10rpx;
}
.btn-box-1 {
left: -40rpx;
}
.btn-box-2 {
right: -40rpx;
}
}
.talk-dialog {
width: 506rpx;
min-height: 234rpx;
margin: 0 auto;
background-color: #06f;
border-radius: 32rpx;
position: relative;
overflow: hidden;
transition: width 0.3s ease;
.text-div {
overflow-y: scroll;
max-height: 434rpx;
padding: 20rpx;
font-size: 32rpx;
color: #fff;
line-height: 40rpx;
}
&.is-translate {
width: 706rpx;
.gif-box {
display: none;
}
}
&.is-out {
background-color: #ff3e49;
&::after {
background-color: #ff3e49;
}
}
.gif-box {
position: absolute;
width: 200rpx;
height: 80rpx;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
&::after {
content: '';
width: 44rpx;
height: 44rpx;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
background-color: #06f;
overflow: hidden;
position: absolute;
bottom: -22rpx;
left: 50%;
margin-left: -22rpx;
}
}
.white-bg-box {
height: 365rpx;
background-color: #fff;
overflow: hidden;
border-radius: 32rpx 32rpx 0 0;
padding: 58rpx 36rpx;
.box-title {
font-size: 32rpx;
font-weight: bold;
line-height: 46rpx;
margin-bottom: 46rpx;
}
.box-btns {
height: 46rpx;
line-height: 46rpx;
display: flex;
justify-content: space-between;
color: #999;
.is-active {
color: #06f;
}
}
}
}
::v-deep .uv-input__content__field-wrapper__field {
padding-right: 60rpx;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
export const emojiList = [
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😗', '☺',
'😚', '😙', '🥲', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨', '😐', '😑', '😶',
'😏', '😒', '🙄', '😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶',
'🥴', '😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', '🧐', '😕', '😟', '🙁', '☹', '😮', '😯', '😲', '😳', '🥺', '😦',
'😧', '😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠', '🤬',
'😈', '👿', '💀', '☠', '*', '🤡', '👹', '👺', '👻', '👽', '👾', '🤖', '😺', '😸', '😹', '😻', '😼', '😽', '🙀',
'😿', '😾', '🙈', '🙉', '🙊', '💋', '💌', '💘', '💝', '💖', '💗', '💓', '💞', '💕', '💟', '❣', '💔', '❤', '🧡',
'💛', '💚', '💙', '💜', '🤎', '🖤', '🤍', '💯', '💢', '💥', '💫', '💦', '💨', '🕳', '💣', '💬', '👁‍🗨', '🗨',
'🗯', '💭', '💤'
]
+8 -8
View File
@@ -84,8 +84,7 @@
</view>
</view>
</scroll-view>
<view class="btn-box btn-box-height font_pf">
<template v-if="[0, 1].includes(tabAct)">
<view class="btn-box btn-box-height font_pf" v-if="[0, 1].includes(tabAct)">
<view class="fl1">
<uv-button type="primary" @click="goto_study()">去学习</uv-button>
</view>
@@ -96,13 +95,14 @@
<uv-button type="success" :disabled="examinationButtonDisabled">去考试</uv-button>
<!-- <uv-button type="success" :disabled="true">去考试</uv-button> -->
</view>
</template>
<template v-if="tabAct === 2">
<view class="fl1" @click="goto_submit()">
<uv-button type="primary" class="send_evaluate" :disabled="isPreview === 'preview'">发布评价</uv-button>
</view>
</template>
<!-- <template v-if="tabAct === 2"> -->
<!-- <view class="fl1" @click="goto_submit()"> -->
<!-- <uv-button type="primary" class="send_evaluate" :disabled="isPreview === 'preview'">发布评价</uv-button> -->
<!-- </view> -->
<!-- </template> -->
</view>
<comment-input v-if="tabAct === 2"></comment-input>
</view>
</template>
+6 -2
View File
@@ -111,9 +111,13 @@
</view>
</scroll-view>
</view>
<TouchBtn class="talk-btns" @uploadVoice="uploadRecordNow" @submitAnswer="submitAnswerNow"
<!-- <TouchBtn class="talk-btns" @uploadVoice="uploadRecordNow" @submitAnswer="submitAnswerNow"
@startRecord="startRecordCallback" :loadingText="sendLoadingText" :isLoading="!answerIsOver"
:adjustPosition="true" :isDisabled="isHistory" /> -->
<touch-btn class="talk-btns" @uploadVoice="uploadRecordNow" @submitAnswer="submitAnswerNow"
@startRecord="startRecordCallback" :loadingText="sendLoadingText" :isLoading="!answerIsOver"
:adjustPosition="true" :isDisabled="isHistory" />
<History ref="historyRef" @showDetail="showHistoryDetail"></History>
<PreviewVue ref="previewRef" />
</scroll-view>
@@ -142,7 +146,7 @@
import {
setupKeyboardHeightListener
} from '@/common/common';
import TouchBtn from '@/pages/course/components/touchBtn.vue';
// import TouchBtn from '@/pages/course/components/touchBtn.vue';
import TalkingItem from './components/talking-item.vue';
import PreviewVue from './components/preview.vue';
import {
@@ -1,324 +0,0 @@
<template>
<view class="live-container" :style="{ top: `${topVal}rpx`, left: `${leftVal}rpx` }">
<cover-view class="pusher-container" @click.stop>
<live-pusher
id="livePusher"
class="livePusher"
:muted="true"
:enable-camera="true"
:auto-focus="true"
:beauty="0"
:whiteness="0"
aspect="9:16"
mode="SD"
></live-pusher>
</cover-view>
<view
class="drag-controller"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 完全透明,只用于接收触摸事件 -->
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 响应式数据
const topVal = ref(150) // 初始顶部位置 (rpx)
const leftVal = ref(0) // 初始左侧位置 (rpx)
const livePusherContext = ref(null)
// 触摸相关数据
const startX = ref(0) // 触摸起始X坐标 (px)
const startY = ref(0) // 触摸起始Y坐标 (px)
const startLeft = ref(0) // 触摸起始时的left值 (rpx)
const startTop = ref(0) // 触摸起始时的top值 (rpx)
const isAnimating = ref(false) // 新增:动画锁,防止多次触发
// 系统信息
const systemInfo = uni.getSystemInfoSync()
const screenWidthPx = systemInfo.windowWidth // 屏幕宽度(px
const screenHeightPx = systemInfo.windowHeight // 屏幕高度(px
const rpxRatio = 750 / screenWidthPx // rpx 转换比例
const SCREEN_WIDTH_RPX = 750 // 新增:统一屏幕宽度(rpx),与设计稿一致
// 容器尺寸 (rpx)
const CONTAINER_WIDTH = 270
const CONTAINER_HEIGHT = 480
console.log('系统信息:', {
screenWidthPx,
screenHeightPx,
rpxRatio,
CONTAINER_WIDTH,
CONTAINER_HEIGHT,
SCREEN_WIDTH_RPX
})
// 初始化
const init = (componentProxy) => {
livePusherContext.value = uni.createLivePusherContext('livePusher', componentProxy)
}
// 切换摄像头
const switchCamera = () => {
if (!livePusherContext.value) return
livePusherContext.value.switchCamera({
success: (res) => {
console.log('livePusher.switchCamera:', JSON.stringify(res))
},
fail: (err) => {
console.error('切换摄像头失败:', err)
}
})
}
// 触摸开始事件 - 增强错误处理
const handleTouchStart = (e) => {
console.log('=== 触摸开始 ===')
// 新增:动画中不响应新的触摸开始
if (isAnimating.value) {
console.log('动画中,忽略触摸开始')
return
}
console.log('事件对象:', e)
console.log('触摸点数组:', e.touches)
try {
if (!e.touches || e.touches.length === 0) {
console.error('没有检测到触摸点')
return
}
const touch = e.touches[0]
console.log('第一个触摸点:', touch)
// 安全地获取触摸坐标
const clientX = touch.clientX || touch.pageX || 0
const clientY = touch.clientY || touch.pageY || 0
startX.value = clientX
startY.value = clientY
// 记录当前容器的位置
startLeft.value = leftVal.value
startTop.value = topVal.value
console.log('记录的起始位置:', {
startX: startX.value,
startY: startY.value,
startLeft: startLeft.value,
startTop: startTop.value
})
} catch (error) {
console.error('触摸开始事件处理出错:', error)
}
}
// 触摸移动事件 - 修复计算逻辑
const handleTouchMove = (e) => {
console.log('=== 触摸移动 ===')
// 防止事件穿透
if (e.stopPropagation) e.stopPropagation()
if (e.preventDefault) e.preventDefault()
// 新增:动画中不响应触摸移动
if (isAnimating.value) return
try {
if (!e.touches || e.touches.length === 0) {
console.log('触摸移动:没有触摸点')
return
}
const touch = e.touches[0]
console.log('移动时的触摸点:', touch)
// 安全地获取当前触摸坐标
const currentX = touch.clientX || touch.pageX || startX.value
const currentY = touch.clientY || touch.pageY || startY.value
console.log('坐标比较:', {
startX: startX.value,
currentX: currentX,
startY: startY.value,
currentY: currentY
})
// 计算偏移量 (px)
const deltaX = currentX - startX.value
const deltaY = currentY - startY.value
console.log('偏移量计算:', { deltaX, deltaY })
// 检查偏移量是否有效
if (isNaN(deltaX) || isNaN(deltaY)) {
console.error('计算出 NaN 偏移量:', { deltaX, deltaY })
return
}
// 将偏移量转换为 rpx 并更新位置
const deltaXInRpx = deltaX * rpxRatio
const deltaYInRpx = deltaY * rpxRatio
const newLeft = startLeft.value + deltaXInRpx
const newTop = startTop.value + deltaYInRpx
// 边界检测 - 修复:使用统一的SCREEN_WIDTH_RPX,而非硬编码750
const maxLeft = SCREEN_WIDTH_RPX - CONTAINER_WIDTH
const screenHeightInRpx = screenHeightPx * rpxRatio
const maxTop = screenHeightInRpx - CONTAINER_HEIGHT
console.log('最终位置计算:', {
deltaX, deltaY,
deltaXInRpx, deltaYInRpx,
newLeft, newTop,
maxLeft, maxTop,
screenHeightInRpx
})
// 确保位置在有效范围内且不是 NaN
if (!isNaN(newLeft) && !isNaN(newTop)) {
leftVal.value = Math.max(0, Math.min(newLeft, maxLeft))
topVal.value = Math.max(0, Math.min(newTop, maxTop))
console.log('更新后的位置:', { left: leftVal.value, top: topVal.value })
} else {
console.error('计算出 NaN 位置:', { newLeft, newTop })
}
} catch (error) {
console.error('触摸移动事件处理出错:', error)
}
}
// 触摸结束事件 - 核心:自动吸附到最近边缘 + 平滑动画
const handleTouchEnd = (e) => {
console.log('=== 触摸结束 ===')
if (isAnimating.value) return
// 1. 计算核心参数
const currentLeft = leftVal.value // 松手时小窗的左偏移
const windowCenterX = currentLeft + CONTAINER_WIDTH / 2 // 小窗中心的X坐标(rpx)
const screenMiddleX = SCREEN_WIDTH_RPX / 2 // 屏幕中线X坐标(rpx
// 2. 判断更近的边缘:中心在左半屏 → 吸附左侧;右半屏 → 吸附右侧
const targetLeft = windowCenterX < screenMiddleX ? 0 : SCREEN_WIDTH_RPX - CONTAINER_WIDTH
const targetTop = topVal.value // Y轴保持松手位置
// 3. 平滑动画过渡
const duration = 140 // 动画时长(ms
const frameCount = 60 // 动画帧数
const stepX = (targetLeft - currentLeft) / frameCount // 每帧X偏移量
const stepY = (targetTop - topVal.value) / frameCount // Y轴无偏移
let currentFrame = 0
isAnimating.value = true
const animationTimer = setInterval(() => {
currentFrame++
// 更新位置
leftVal.value += stepX
topVal.value += stepY
// 动画结束
if (currentFrame >= frameCount) {
clearInterval(animationTimer)
// 强制修正到目标位置(避免浮点误差)
leftVal.value = targetLeft
topVal.value = targetTop
isAnimating.value = false
console.log('吸附完成,最终位置:', { left: leftVal.value, top: topVal.value })
}
}, duration / frameCount)
// 仅重置触摸起始坐标
startX.value = 0
startY.value = 0
}
// 开启预览
const startPreview = () => {
console.log('摄像头开启预览')
if (!livePusherContext.value) return
livePusherContext.value.startPreview({
success: (res) => {
console.log('摄像头开启成功')
},
fail: (err) => {
console.error('开启预览失败:', err)
}
})
}
// 关闭预览
const stopPreview = () => {
console.log('关闭预览')
if (!livePusherContext.value) return
livePusherContext.value.stopPreview({
success: (res) => {
console.log('livePusher.stopPreview:', JSON.stringify(res))
},
fail: (err) => {
console.error('关闭预览失败:', err)
}
})
}
// 组件挂载后的初始化
onMounted(() => {
console.log('组件挂载完成')
// 初始化左侧位置为屏幕右侧
leftVal.value = SCREEN_WIDTH_RPX - CONTAINER_WIDTH // 修复:使用容器实际宽度,而非硬编码280
console.log('初始化位置:', {
screenWidthPx,
rpxRatio,
SCREEN_WIDTH_RPX,
leftVal: leftVal.value
})
})
// 暴露方法给父组件
defineExpose({
init,
switchCamera,
stopPreview,
startPreview
})
</script>
<style scoped>
.live-container {
position: absolute;
overflow: hidden;
border-radius: 20rpx;
width: 270rpx;
height: 480rpx;
background-color: red;
display: flex;
}
.pusher-container {
width: 270rpx;
height: 480rpx;
}
.drag-controller {
width: 100%;
height: 100%;
background-color: rgba(255, 0, 0, 0.1); /* 半透明便于调试 */
position: absolute;
z-index: 999;
}
</style>
@@ -1,322 +0,0 @@
<template>
<view class="live-container" :style="{ top: `${topVal}px`, left: `${leftVal}px` }">
<cover-view class="pusher-container" @click.stop>
<live-pusher
id="livePusher"
class="livePusher"
:muted="true"
:enable-camera="true"
:auto-focus="true"
:beauty="0"
:whiteness="0"
aspect="9:16"
mode="SD"
></live-pusher>
</cover-view>
<view
class="drag-controller"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 响应式数据 - 直接使用px单位
const topVal = ref(40) // 初始顶部位置 (px)
const leftVal = ref(0) // 初始左侧位置 (px)
const livePusherContext = ref(null)
// 触摸相关数据
const startX = ref(0) // 触摸起始X坐标 (px)
const startY = ref(0) // 触摸起始Y坐标 (px)
const startLeft = ref(0) // 触摸起始时的left值 (px)
const startTop = ref(0) // 触摸起始时的top值 (px)
const isAnimating = ref(false) // 动画锁,防止多次触发
// 系统信息 - 直接使用px,无需rpx换算
const systemInfo = uni.getSystemInfoSync()
const screenWidthPx = systemInfo.windowWidth // 屏幕宽度(px
const screenHeightPx = systemInfo.windowHeight // 屏幕高度(px
// 容器尺寸 (px) - 直接定义像素尺寸
const CONTAINER_WIDTH = 135 // 对应原来的270rpx (750设计稿下 270/2=135px)
const CONTAINER_HEIGHT = 240 // 对应原来的480rpx (750设计稿下 480/2=240px)
console.log('系统信息:', {
screenWidthPx,
screenHeightPx,
CONTAINER_WIDTH,
CONTAINER_HEIGHT
})
// 初始化
const init = (componentProxy) => {
livePusherContext.value = uni.createLivePusherContext('livePusher', componentProxy)
}
// 切换摄像头
const switchCamera = () => {
if (!livePusherContext.value) return
livePusherContext.value.switchCamera({
success: (res) => {
console.log('livePusher.switchCamera:', JSON.stringify(res))
},
fail: (err) => {
console.error('切换摄像头失败:', err)
}
})
}
// 触摸开始事件
const handleTouchStart = (e) => {
console.log('=== 触摸开始 ===')
// 结束动画中和,不能开始
if (isAnimating.value) return
console.log('事件对象:', e)
console.log('触摸点数组:', e.touches)
try {
if (!e.touches || e.touches.length === 0) {
console.error('没有检测到触摸点')
return
}
const touch = e.touches[0]
// 安全地获取触摸坐标
const clientX = touch.screenX
const clientY = touch.screenY
startX.value = clientX
startY.value = clientY
// 记录当前容器的位置
startLeft.value = leftVal.value
startTop.value = topVal.value
console.log('记录的起始位置:', {
startX: startX.value,
startY: startY.value,
startLeft: startLeft.value,
startTop: startTop.value
})
} catch (error) {
console.error('触摸开始事件处理出错:', error)
}
}
// 触摸移动事件 - 移除rpx换算,直接使用px计算
const handleTouchMove = (e) => {
console.log('=== 触摸移动 ===')
// 防止事件穿透
if (e.stopPropagation) e.stopPropagation()
if (e.preventDefault) e.preventDefault()
// 动画中不响应触摸移动
if (isAnimating.value) return
try {
if (!e.touches || e.touches.length === 0) {
console.log('触摸移动:没有触摸点')
return
}
const touch = e.touches[0]
console.log('移动时的触摸点:', touch)
// 安全地获取当前触摸坐标
const currentX = touch.screenX
const currentY = touch.screenY
console.log('坐标比较:', {
startX: startX.value,
currentX: currentX,
startY: startY.value,
currentY: currentY
})
// 计算偏移量 (px) - 直接使用px,无需换算
const deltaX = currentX - startX.value
const deltaY = currentY - startY.value
console.log('偏移量计算:', { deltaX, deltaY })
// 检查偏移量是否有效
if (isNaN(deltaX) || isNaN(deltaY)) {
console.error('计算出 NaN 偏移量:', { deltaX, deltaY })
return
}
// 直接更新位置(px),无需rpx转换
const newLeft = startLeft.value + deltaX
const newTop = startTop.value + deltaY
// 边界检测 - 使用px单位计算边界
const maxLeft = screenWidthPx - CONTAINER_WIDTH
const maxTop = screenHeightPx - CONTAINER_HEIGHT
console.log('最终位置计算:', {
deltaX, deltaY,
newLeft, newTop,
maxLeft, maxTop,
screenWidthPx,
screenHeightPx
})
// 确保位置在有效范围内且不是 NaN
if (!isNaN(newLeft) && !isNaN(newTop)) {
leftVal.value = Math.max(0, Math.min(newLeft, maxLeft))
topVal.value = Math.max(0, Math.min(newTop, maxTop))
console.log('更新后的位置:', { left: leftVal.value, top: topVal.value })
} else {
console.error('计算出 NaN 位置:', { newLeft, newTop })
}
} catch (error) {
console.error('触摸移动事件处理出错:', error)
}
}
// 触摸结束事件 - 吸附逻辑改为px计算
const handleTouchEnd = (e) => {
console.log('=== 触摸结束 ===')
if (isAnimating.value) return
// 1. 计算核心参数(px单位)
const currentLeft = leftVal.value // 松手时小窗的左偏移
const windowCenterX = currentLeft + CONTAINER_WIDTH / 2 // 小窗中心的X坐标(px)
const screenMiddleX = screenWidthPx / 2 // 屏幕中线X坐标(px
// 2. 判断更近的边缘:中心在左半屏 → 吸附左侧;右半屏 → 吸附右侧
const targetLeft = windowCenterX < screenMiddleX ? 0 : screenWidthPx - CONTAINER_WIDTH
const targetTop = topVal.value // Y轴保持松手位置
// 3. 平滑动画过渡
const duration = 160 // 动画时长(ms
const frameCount = 60 // 动画帧数
const stepX = (targetLeft - currentLeft) / frameCount // 每帧X偏移量
const stepY = (targetTop - topVal.value) / frameCount // Y轴无偏移
let currentFrame = 0
isAnimating.value = true
const animationTimer = setInterval(() => {
currentFrame++
// 更新位置
leftVal.value += stepX
topVal.value += stepY
// 动画结束
if (currentFrame >= frameCount) {
clearInterval(animationTimer)
// 强制修正到目标位置(避免浮点误差)
leftVal.value = targetLeft
topVal.value = targetTop
isAnimating.value = false
console.log('吸附完成,最终位置:', { left: leftVal.value, top: topVal.value })
}
}, duration / frameCount)
// 仅重置触摸起始坐标
startX.value = 0
startY.value = 0
}
// 开启预览
const startPreview = () => {
console.log('摄像头开启预览')
if (!livePusherContext.value) return
livePusherContext.value.startPreview({
success: (res) => {
console.log('摄像头开启成功')
},
fail: (err) => {
console.error('开启预览失败:', err)
}
})
}
// 关闭预览
const stopPreview = () => {
console.log('关闭预览')
if (!livePusherContext.value) return
livePusherContext.value.stopPreview({
success: (res) => {
console.log('livePusher.stopPreview:', JSON.stringify(res))
},
fail: (err) => {
console.error('关闭预览失败:', err)
}
})
}
// 组件挂载后的初始化
onMounted(() => {
console.log('组件挂载完成')
// 初始化左侧位置为屏幕右侧(px单位)
leftVal.value = screenWidthPx - CONTAINER_WIDTH
console.log('初始化位置:', {
screenWidthPx,
leftVal: leftVal.value
})
})
// 暴露方法给父组件
defineExpose({
init,
switchCamera,
stopPreview,
startPreview
})
</script>
<style scoped>
.live-container {
position: absolute;
overflow: hidden;
border-radius: 10px;
width: 135px; /* 对应原来的270rpx */
height: 240px; /* 对应原来的480rpx */
background-color: red;
display: flex;
}
.livePusher{
width: 135px;
height: 240px;
border-radius: 10px;
overflow: hidden;
}
.pusher-container {
width: 135px; /* 对应原来的270rpx */
height: 240px; /* 对应原来的480rpx */
border-radius: 20px; /* 圆角大小,根据需求调整 */
overflow: hidden; /* 溢出裁剪,必须设置 */
/* 可选:添加阴影增强视觉效果 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.drag-controller {
width: 135px; /* 对应原来的270rpx */
height: 240px; /* 对应原来的480rpx */
position: absolute;
z-index: 550;
border-radius: 20px; /* 圆角大小,根据需求调整 */
overflow: hidden; /* 溢出裁剪,必须设置 */
/* 可选:添加阴影增强视觉效果 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
+2
View File
@@ -47,6 +47,8 @@ export enum ControlCode {
AI_ANSWER_OVER = 0b0100, // 本轮AI回答的文本内容已经全部返回
AI_CLUE = 0b0101, // 需要AI提示命令(发后端)
AI_CLUE_OVER = 0b0110, // AI提示命令已经全部返回
FRNOT_TO_SERVER_OVER = 0b0111, // 语音按钮已经抬起
SERVER_TO_FRNOT_OVER = 0b1000, // 后端已经返回本次全部内容
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

+83
View File
@@ -0,0 +1,83 @@
{
"id": "ty-recording",
"displayName": "ty-recording",
"version": "1.0.0",
"description": "ty-recording",
"keywords": [
"ty-recording"
],
"repository": "",
"engines": {
"HBuilderX": "^3.6.8"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "",
"data": "",
"permissions": ""
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "u",
"aliyun": "u",
"alipay": "u"
},
"client": {
"Vue": {
"vue2": "u",
"vue3": "u"
},
"App": {
"app-android": "u",
"app-ios": "u",
"app-harmony": "u"
},
"H5-mobile": {
"Safari": "u",
"Android Browser": "u",
"微信浏览器(Android)": "u",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
# ty-recording
### 开发文档
[UTS 语法](https://uniapp.dcloud.net.cn/tutorial/syntax-uts.html)
[UTS API插件](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html)
[UTS uni-app兼容模式组件](https://uniapp.dcloud.net.cn/plugin/uts-component.html)
[UTS 标准模式组件](https://doc.dcloud.net.cn/uni-app-x/plugin/uts-vue-component.html)
[Hello UTS](https://gitcode.net/dcloud/hello-uts)
@@ -0,0 +1,3 @@
{
"minSdkVersion": "35"
}
@@ -0,0 +1,109 @@
import { StartAudioRecord, StartAudioRecordOptions } from '../interface.uts';
import { StartAudioRecordFailImpl,AudioErrorCode } from '../unierror.uts';
/**
* 引用Android音频相关系统库
*/
import AudioRecord from "android.media.AudioRecord";
import MediaRecorder from "android.media.MediaRecorder";
import AudioFormat from "android.media.AudioFormat";
import { UTSAndroid } from "io.dcloud.uts";
import HandlerThread from "android.os.HandlerThread";
import Handler from "android.os.Handler";
import AtomicBoolean from "java.util.concurrent.atomic.AtomicBoolean";
const isRecording = new AtomicBoolean(false); // 原子布尔值,保证线程安全
// 1. 音频采集参数(极简配置,适配大部分设备)
const sampleRate : Int = 16000; // 采样率
const channelConfig = AudioFormat.CHANNEL_IN_MONO; // 单声道
const audioFormat = AudioFormat.ENCODING_PCM_16BIT; // 16位PCM
console.log('channelConfig', channelConfig);
console.log('audioFormat', audioFormat);
const frameSize = 1024; // 对应JS的frameSize:1024
const gain = 1.0; // 对应JS的gain:1.0
const fromSampleRate = 48000; // 设备原始采样率(JS的fromSampleRate:48000
const frameByteSize = frameSize * 2 * 1;
const minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
// 最终缓冲区大小:取最小缓冲区和帧字节数的最大值(避免缓冲区过小)
const bufferSize = minBufferSize * 2;
console.log('bufferSize', bufferSize);
let audioRecord : AudioRecord | null = null;
let recordThread : Thread | null = null;
// let isRecording : boolean = false;
// ========== 2. 对外暴露的便捷方法(符合UTS导出规范) ==========
@UTSJS.keepAlive
export function startAudioRecord(options : StartAudioRecordOptions) : void {
console.log('启动', isRecording.get());
if (isRecording.get()) return; // 避免重复启动
const _audioRecord = new AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize
);
if (_audioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
const err = new StartAudioRecordFailImpl(AudioErrorCode.AUDIO_FORMAT_ILLEGAL);
options.onError?.(err);
return;
}
audioRecord = _audioRecord;
isRecording.set(true)
const _recordThread = new Thread(() => {
_audioRecord.startRecording(); // 开始采集
console.log('AudioRecord已启动采集');
const buffer = new ByteArray(bufferSize); // 缓冲区只创建一次,避免重复分配内存
while (isRecording.get()) {
const readSize = _audioRecord.read(buffer, 0, bufferSize);
if (readSize > 0) {
const data = new ByteArray(readSize);
java.lang.System.arraycopy(buffer, 0, data, 0, readSize);
options.onFrame?.(data)
}
}
// 停止采集
if (_audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
console.log('停止采集xxxxxxxxx');
_audioRecord.stop();
options.onStop?.()
}
});
_recordThread.start();
recordThread = _recordThread
}
export function stopAudioRecord() : void {
console.log('停止麦克风采集', isRecording.get());
if (!isRecording.get()) return;
isRecording.set(false)
// 释放AudioRecord(局部常量暂存)
const _audioRecord = audioRecord;
if (_audioRecord != null) {
try {
if (_audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
_audioRecord.stop();
}
_audioRecord.release();
} catch (e) { /* 忽略异常 */ }
}
audioRecord = null;
// 等待线程结束
console.log('等待线程结束');
const _recordThread = recordThread;
if (_recordThread != null) {
try {
_recordThread.join(1000); // 等待1秒,超时则放弃(避免卡死)
console.log("后台线程已停止");
} catch (e) {
console.warn("停止后台线程时出现异常:", e);
} finally {
recordThread = null;
}
}
}
@@ -0,0 +1,3 @@
{
"deploymentTarget": "13"
}
@@ -0,0 +1,11 @@
/**
* 引用 iOS 系统库,示例如下:
* import { UIDevice } from "UIKit";
* [可选实现,按需引入]
*/
/* 引入 interface.uts 文件中定义的变量 */
import { MyApiOptions, MyApiResult, MyApi, MyApiSync } from '../interface.uts';
/* 引入 unierror.uts 文件中定义的变量 */
import { MyApiFailImpl } from '../unierror';
@@ -0,0 +1,64 @@
/**
* interface.uts
* uts插件接口定义文件,按规范定义接口文件可以在HBuilderX中更好的做到语法提示
*/
import { AudioErrorCode } from "./unierror.uts"
// 成功开启录音的回调参数:返回录音基础配置
export type AudioStartRes = {
recordId : string; // 录音唯一标识(方便多录音实例管理)
sampleRate : number; // 采样率(如16000
format : string; // 录音格式(如pcm/mp3
startTime : number; // 开启时间戳(毫秒)
};
// 录音结束并返回文件的回调参数:核心是录音文件信息
export type AudioFileRes = {
filePath : string; // 录音文件本地路径(UTS/Uniapp 通用路径)
fileSize : number; // 文件大小(字节)
duration : number; // 录音时长(秒)
format : string; // 文件格式(如pcm/mp3
endTime : number; // 结束时间戳(毫秒)
};
/**
* 错误码
* 根据uni错误码规范要求,建议错误码以90开头,以下是错误码示例:
* - 9010001 错误信息1
* - 9010002 错误信息2
*/
// export type AudioErrorCode = 9010001 | 9010002;
/**
* 录音错误的回调参数:带错误码+错误信息,方便问题定位
*/
export interface AudioErrorRes extends IUniError {
errCode : number
};
/**
* 开启录音的完整参数配置
* 所有回调职责边界清晰,无模糊命名
*/
export type StartAudioRecordOptions = {
autoStopDuration ?: number; // 自动关闭时长,单位【秒】;传正数值则超过该时间自动关闭,不传/非正数不开启
// 【实时持续回调】- 录制过程中高频触发
onFrame ?: (frameData : any) => void; // 实时音频帧回调(每帧一次)
onVolume ?: (volume : number) => void; // 实时音量回调(如每秒5次,按设备采集频率)
// 【一次性状态回调】- 特定节点仅触发一次
onStart ?: (res : AudioStartRes) => void; // 成功开启录音的回调(仅触发1次)
onError ?: (res : AudioErrorRes) => void; // 录音错误回调(任意阶段出错都触发,仅1次)
onStop ?: () => void; // 录音结束的纯状态回调(仅通知结束,无返回,1次)
onFile ?: (res : AudioFileRes) => void; // 录音结束后,返回录音文件的回调(1次,核心)
};
/* 函数定义 */
export type StartAudioRecord = (options : StartAudioRecordOptions) => void
@@ -0,0 +1,146 @@
/* 此规范为 uni 规范,可以按照自己的需要选择是否实现 */
import { AudioErrorRes } from "./interface.uts"
/**
* 错误主题
* 注意:错误主题一般为插件名称,每个组件不同,需要使用时请更改。
* [可选实现]
*/
export const UniErrorSubject = 'ty-recording';
/**
* 错误信息
* @UniError
*/
/**
* 音频录制错误码枚举
* 前缀901:专属音频模块错误码
* 分段规则:9010001-9010009(权限)、9010010-9010019(初始化)、9010020-9010029(采集)、9010030-9010039(线程)、9010040-9010049(资源)、9010050-9010059(参数)、9010060-9010069(系统/硬件)、9010070+(预留)
* @UniError
*/
export enum AudioErrorCode {
// 权限相关(9010001-9010009
MIC_PERMISSION_NONE = 9010001, // 麦克风权限未授予
MIC_PERMISSION_DENIED = 9010002, // 麦克风权限被永久拒绝
MIC_PERMISSION_GET_FAIL = 9010003,// 获取麦克风权限状态失败
// 初始化相关(9010010-9010019
AUDIO_RECORD_INIT_FAIL = 9010010, // AudioRecord实例初始化失败
AUDIO_RECORD_NOT_INIT = 9010011, // AudioRecord未初始化就调用录制/停止
AUDIO_RECORD_CREATE_FAIL = 9010012,// AudioRecord实例创建失败(系统资源不足)
// 采集过程相关(9010020-9010029
RECORD_READ_DATA_FAIL = 9010020, // 音频采集读取数据失败
RECORD_INTERRUPTED = 9010021, // 音频采集被中断(如麦克风被抢占)
RECORD_BUFFER_EMPTY = 9010022, // 采集缓冲区无有效数据
RECORD_START_FAIL = 9010023, // 调用startRecording启动采集失败
// 线程相关(9010030-9010039
RECORD_THREAD_CREATE_FAIL = 9010030,// 采集线程创建失败
RECORD_THREAD_START_FAIL = 9010031, // 采集线程启动失败
RECORD_THREAD_INTERRUPT_FAIL = 9010032,// 采集线程中断失败
RECORD_THREAD_CRASH = 9010033, // 采集线程运行崩溃
// 资源释放相关(9010040-9010049
RECORD_STOP_FAIL = 9010040, // 调用stop停止采集失败
AUDIO_RECORD_RELEASE_FAIL = 9010041,// AudioRecord资源释放失败
RECORD_REPEAT_RELEASE = 9010042, // 音频资源重复释放
RECORD_RESOURCE_LEAK = 9010043, // 音频资源泄漏(未正常释放)
// 参数配置相关(9010050-9010059
SAMPLE_RATE_NOT_SUPPORT = 9010050, // 采样率不被设备支持
CHANNEL_CONFIG_ILLEGAL = 9010051, // 声道配置非法/不支持
AUDIO_FORMAT_ILLEGAL = 9010052, // 音频格式非法/不支持
BUFFER_SIZE_TOO_SMALL = 9010053, // 采集缓冲区大小过小(低于系统最小值)
BUFFER_SIZE_ILLEGAL = 9010054, // 采集缓冲区大小配置非法
// 系统/硬件/外部冲突(9010060-9010069
MIC_OCCUPIED = 9010060, // 麦克风被其他应用占用
AUDIO_HARDWARE_ERROR = 9010061, // 音频硬件故障
SYSTEM_RESOURCE_INSUFFICIENT = 9010062,// 系统资源不足(无法创建音频实例)
AUDIO_SERVICE_EXCEPTION = 9010063,// 系统音频服务异常/未启动
// 通用业务异常(9010070-9010079
RECORD_REPEAT_START = 9010070, // 重复启动音频录制
RECORD_NOT_RUNNING = 9010071, // 未在录制状态却调用停止/暂停
BYTE_ARRAY_COPY_FAIL = 9010072, // 音频数据缓冲区拷贝失败
// 预留扩展(9010080+
RESERVED_01 = 9010080
}
/**
* 音频录制错误码-错误信息映射表
* 与AudioErrorCode枚举一一对应,提供友好的用户/开发提示
* @UniError
*/
export const AudioErrors : Map<AudioErrorCode, string> = new Map([
// 权限相关
[AudioErrorCode.MIC_PERMISSION_NONE, '麦克风权限未授予,请先在设置中开启'],
[AudioErrorCode.MIC_PERMISSION_DENIED, '麦克风权限被永久拒绝,请前往应用设置手动开启'],
[AudioErrorCode.MIC_PERMISSION_GET_FAIL, '获取麦克风权限状态失败,请检查应用权限配置'],
// 初始化相关
[AudioErrorCode.AUDIO_RECORD_INIT_FAIL, '音频录制实例初始化失败,可能是参数不支持或系统异常'],
[AudioErrorCode.AUDIO_RECORD_NOT_INIT, '音频录制实例未初始化,无法执行操作'],
[AudioErrorCode.AUDIO_RECORD_CREATE_FAIL, '创建音频录制实例失败,系统音频资源不足'],
// 采集过程相关
[AudioErrorCode.RECORD_READ_DATA_FAIL, '音频采集读取数据失败,建议停止后重新启动'],
[AudioErrorCode.RECORD_INTERRUPTED, '音频采集被中断,麦克风可能被其他应用抢占'],
[AudioErrorCode.RECORD_BUFFER_EMPTY, '音频采集缓冲区无有效数据,采集异常'],
[AudioErrorCode.RECORD_START_FAIL, '启动音频采集失败,实例状态异常'],
// 线程相关
[AudioErrorCode.RECORD_THREAD_CREATE_FAIL, '创建音频采集线程失败,系统资源不足'],
[AudioErrorCode.RECORD_THREAD_START_FAIL, '启动音频采集线程失败,线程状态异常'],
[AudioErrorCode.RECORD_THREAD_INTERRUPT_FAIL, '中断音频采集线程失败,线程已异常'],
[AudioErrorCode.RECORD_THREAD_CRASH, '音频采集线程运行崩溃,具体原因请查看日志'],
// 资源释放相关
[AudioErrorCode.RECORD_STOP_FAIL, '停止音频采集失败,录制实例状态异常'],
[AudioErrorCode.AUDIO_RECORD_RELEASE_FAIL, '释放音频录制资源失败,可能存在资源泄漏'],
[AudioErrorCode.RECORD_REPEAT_RELEASE, '音频录制资源已释放,请勿重复执行释放操作'],
[AudioErrorCode.RECORD_RESOURCE_LEAK, '音频录制资源未正常释放,建议检查停止逻辑'],
// 参数配置相关
[AudioErrorCode.SAMPLE_RATE_NOT_SUPPORT, '当前设备不支持该采样率,建议切换为16000/44100Hz'],
[AudioErrorCode.CHANNEL_CONFIG_ILLEGAL, '声道配置非法或当前设备不支持,建议使用单声道'],
[AudioErrorCode.AUDIO_FORMAT_ILLEGAL, '音频格式非法或当前设备不支持,建议使用PCM_16BIT'],
[AudioErrorCode.BUFFER_SIZE_TOO_SMALL, '采集缓冲区过小,已自动调整为系统最小缓冲区'],
[AudioErrorCode.BUFFER_SIZE_ILLEGAL, '采集缓冲区大小配置非法,已使用系统默认值'],
// 系统/硬件/外部冲突
[AudioErrorCode.MIC_OCCUPIED, '麦克风被其他应用占用,请关闭后重新尝试'],
[AudioErrorCode.AUDIO_HARDWARE_ERROR, '音频硬件故障,请检查设备麦克风是否正常'],
[AudioErrorCode.SYSTEM_RESOURCE_INSUFFICIENT, '系统资源不足,无法创建音频录制实例'],
[AudioErrorCode.AUDIO_SERVICE_EXCEPTION, '系统音频服务异常,请重启设备后重新尝试'],
// 通用业务异常
[AudioErrorCode.RECORD_REPEAT_START, '当前正在录制中,请勿重复启动音频录制'],
[AudioErrorCode.RECORD_NOT_RUNNING, '未在音频录制状态,无需执行停止操作'],
[AudioErrorCode.BYTE_ARRAY_COPY_FAIL, '音频数据缓冲区拷贝失败,采集数据可能损坏'],
// 预留扩展
[AudioErrorCode.RESERVED_01, '预留错误码,暂未定义']
]);
/**
* 错误对象实现
*/
export class StartAudioRecordFailImpl extends UniError implements AudioErrorRes {
/**
* 错误对象构造函数
*/
constructor(errCode : AudioErrorCode) {
super();
this.errSubject = UniErrorSubject;
// @ts-ignore 忽略"value属性不存在"的假报错(运行时存在)
this.errCode = errCode.value as number;
this.errMsg = AudioErrors.get(errCode) ?? "";
}
}
@@ -0,0 +1,2 @@
export function startAudioRecord(callback) {}
export function stopAudioRecord() {}
Binary file not shown.