Merge branch 'develop' of http://25.13.9.101:9000/K17_AITS/tra-app into develop

This commit is contained in:
杨航
2025-12-12 13:13:26 +08:00
8 changed files with 69 additions and 1316 deletions
+2 -10
View File
@@ -12,11 +12,8 @@
:whiteness="0"
aspect="9:16"
mode="SD"
device-position="front"
orientation
></live-pusher>
</cover-view>
<!-- <cover-view class="corner-mask"></cover-view> -->
</view>
</view>
</template>
@@ -27,9 +24,8 @@
const livePusherContext = ref(null);
const isPreviewing = ref(false);
const init = (componentProxy) => {
console.log('init');
livePusherContext.value = uni.createLivePusherContext('livePusher', componentProxy);
livePusherContext.value.switchCamera()
};
// 切换摄像头
const switchCamera = () => {
@@ -59,13 +55,10 @@
console.error('complete预览:', err);
}
});
if (getPhoneEnvBool('AND')) {
console.log('切换前置');
// setTimeout(() =>{
// switchCamera()
// }, 1000)
}
});
};
@@ -83,7 +76,6 @@
console.error('complete预览:', err);
}
});
isPreviewing.value = false;
};
defineExpose({ init, switchCamera, stopPreview, startPreview });
-149
View File
@@ -1,149 +0,0 @@
import {
ref,
onUnmounted,
watch
} from 'vue';
import {
ProtocolCodec,
MessageType,
SerializationType,
CompressionType,
ControlCommand,
ProtocolConst
} from './ProtocolCodec';
/**
* 麦克风操作Hook(封装开始/停止录音、状态管理)
* @param {Object} options - 录音配置项
* @param {number} options.duration - 最大录音时长(秒,默认60)
* @param {string} options.format - 录音格式(mp3/wav/pcm,默认mp3
* @param {number} options.sampleRate - 采样率(默认16000
* @param {number} options.bitRate - 码率(默认16000
* @param {Function} options.onData - 实时音频数据回调(仅H5/PCM格式生效)
* @param {Function} options.onStop - 结束回调
* @param {Function} options.onError - 错误回调
* @returns {Object} 录音控制方法+状态
*/
export default function useMicrophone(options = {}) {
console.log('options', options);
const send = options.send
const recordFrame = ref(null)
const renderJSModule = ref(null)
// 默认配置
const defaultOptions = {
duration: 60,
format: 'mp3',
sampleRate: 16000,
bitRate: 16000,
onData: () => {},
onStop: () => {},
onError: (err) => console.error('录音错误:', err)
};
const finalOptions = {
...defaultOptions,
...options
};
// 响应式状态
const isRecording = ref(false); // 是否正在录音
const recordTime = ref(0); // 录音时长(秒)
const recordResult = ref(null); // 录音结果(临时文件路径/Blob)
const recorderManager = ref(null); // 录音管理器实例(uniapp/小程序)
const audioContext = ref(null); // AudioContextH5
const mediaRecorder = ref(null); // MediaRecorderH5
const timer = ref(null); // 录音时长定时器
// 平台判断
const platform = uni.getSystemInfoSync().platform;
// ==================== 核心方法:开始录音 ====================
const startRecording = async () => {
// 防止重复点击
if (isRecording.value) return;
// 重置状态
recordTime.value = 0;
recordResult.value = null;
console.log('开始', recordFrame.value);
recordFrame.value.start({
sampleRate: 16000,
frameSize: 1024,
gain: 1.0,
onFrameRecorded: ({ isLastFrame, frameBuffer }) => {
// console.log('录音返回', frameBuffer);
const data = ProtocolCodec.pack(MessageType.AUDIO_DATA, frameBuffer)
// console.log('录音返回', data);
send(data)
// try {
// ws.value.send({
// data: ProtocolCodec.pack(MessageType.AUDIO_DATA, frameBuffer)
// });
// } catch (e) {}
},
onDecibels: (decibels) => {
// this.currentDecibels = decibels;
}
});
// this.isRecording = true;
// this.status = '录音中...';
// isRecording.value = true;
};
// ==================== 核心方法:停止录音 ====================
const stopRecord = () => {
// if (!isRecording.value) return;
// try {
// if (isH5) {
// mediaRecorder.value?.stop();
// } else if (isMiniProgram || platform === 'app-plus') {
// recorderManager.value?.stop();
// }
// isRecording.value = false;
// clearInterval(timer.value);
// } catch (err) {
// finalOptions.onError(`停止录音失败:${err.message}`);
// }
};
// ==================== 资源清理 ====================
const cleanup = () => {
// stopRecord();
// // H5端额外清理
// if (isH5) {
// if (mediaRecorder.value) mediaRecorder.value = null;
// if (audioContext.value) audioContext.value.close();
// // 释放Blob URL
// if (recordResult.value?.url) {
// URL.revokeObjectURL(recordResult.value.url);
// }
// }
// // 小程序/APP端清理
// if (recorderManager.value) recorderManager.value = null;
};
// 组件卸载时自动清理
// onUnmounted(() => {
// cleanup();
// });
// 监听最大时长,自动停止
// watch(
// () => recordTime.value,
// (val) => {
// if (val >= finalOptions.duration) {
// stopRecord();
// }
// }
// );
// 返回控制方法+状态
return {
isRecording,
renderJSModule,
startRecording,
recordFrame
};
};
-129
View File
@@ -1,129 +0,0 @@
import {
ref,
onUnmounted,
watch
} from 'vue';
/**
* 扬声器管理Hook(音频输出)
* @param {Object} options - 播放配置
* @param {number} options.sampleRate - 播放采样率(默认16000,需与音频源一致)
* @param {number} options.volume - 初始音量(0~1,默认1
* @param {boolean} options.autoPlay - 是否自动播放(默认false,需用户交互触发)
* @param {Function} options.onPlay - 播放开始回调
* @param {Function} options.onError - 播放错误回调
* @returns {Object} 播放控制方法+状态
*/
export const useSpeaker = (options = {}) => {
// 默认配置
const defaultOptions = {
sampleRate: 16000,
volume: 1.0,
autoPlay: false,
onPlay: () => {},
onError: (err) => console.error('扬声器播放错误:', err)
};
const finalOptions = {
...defaultOptions,
...options
};
// 响应式状态
const isPlaying = ref(false); // 是否正在播放
const volume = ref(finalOptions.volume); // 播放音量(0~1
const audioContext = ref(null); // 音频播放上下文(H5
const audioBufferSource = ref(null); // 音频缓冲源(播放PCM流)
const audioElement = ref(null); // 音频元素(播放文件/URL
const playQueue = ref([]); // 音频数据播放队列(适配实时流)
// 平台判断
const isH5 = uni.getSystemInfoSync().platform === 'h5';
// ==================== 初始化播放上下文 ====================
const initAudioContext = () => {
};
// ==================== 核心方法:播放PCM音频流(实时) ====================
/**
* 播放PCM音频数据(适配WebSocket传输的实时流)
* @param {ArrayBuffer} pcmData - PCM音频数据(16位单声道)
*/
const playPCM = (pcmData) => {
};
// ==================== 控制方法:暂停/停止/调节音量 ====================
const pausePlay = () => {
if (!isPlaying.value) return;
if (isH5) {
if (audioBufferSource.value) {
audioBufferSource.value.stop();
audioBufferSource.value = null;
}
audioElement.value?.pause();
} else {
uni.createInnerAudioContext().pause();
}
isPlaying.value = false;
};
const stopPlay = () => {
pausePlay();
playQueue.value = []; // 清空播放队列
if (audioContext.value) {
audioContext.value.close().then(() => {
audioContext.value = null;
});
}
audioElement.value = null;
};
const setVolume = (val) => {
if (val < 0) val = 0;
if (val > 1) val = 1;
volume.value = val;
if (isH5) {
audioContext.value?.destination.setVolume(val);
audioElement.value.volume = val;
} else {
uni.createInnerAudioContext().volume = val;
}
};
// ==================== 资源清理 ====================
const cleanup = () => {
stopPlay();
isPlaying.value = false;
volume.value = finalOptions.volume;
playQueue.value = [];
};
// 组件卸载时自动清理
onUnmounted(() => {
cleanup();
});
// 监听音量变化,同步更新
watch(volume, (newVal) => {
setVolume(newVal);
});
// 返回控制方法+状态
return {
// 响应式状态
isPlaying, // 是否正在播放
volume, // 当前音量(0~1
// 核心方法
playPCM, // 播放实时PCM音频流(适配WebSocket
playAudio, // 播放音频文件/URL
pausePlay, // 暂停播放
stopPlay, // 停止播放
setVolume, // 调节音量(0~1
cleanup // 手动清理资源
};
};
+67 -49
View File
@@ -1,11 +1,13 @@
<template>
<view>
<image-preview :src="dataInfo.ossAddr" class="show-box-avatar"></image-preview>
<view class="show-box">
<view class="show-box" :class="{ isVideo: isVideoStyle }">
<nav-bar :is_seat="true"></nav-bar>
<view class="switch-div">
<!-- <view class="switch-text">场景提示</view> -->
<!-- <switch class="switch" :checked="!sceneDescShow" @click="switchChange" /> -->
<view class="title-time" v-if="isRecording">{{ formattedTime }}</view>
<view class="title-tip" v-if="!isRecording">正在接通中</view>
</view>
<view class="fl1-div-1"></view>
<view class="role-info">
@@ -34,13 +36,13 @@
import talkSpeaker from './components/talk-speaker.vue';
import talkCamera from './components/talk-camera';
import useWebSocket from './js/useWebSocket';
import useMicrophone from './js/useMicrophone';
const talkMicrophoneRef = ref(null);
const talkSpeakerRef = ref(null);
const talkCameraRef = ref(null);
const { initConnection, close, send, isRecording } = useWebSocket();
const isVideo = ref(false);
const isVideoStyle = computed(() => dataInfo.traInterTyp === '03');
const dataInfo = reactive({
traId: '',
@@ -62,7 +64,7 @@
// 关闭通话
const closeTalking = async () => {
// todo 防抖
common.show('', '挂断电话将结束训练,是否确认?').then((res) => {
common.show('', '挂断'+(isVideoStyle.value?'视频':'电话')+'将结束训练,是否确认?').then((res) => {
if (res) {
destroy(); // 走关闭销毁流程
}
@@ -70,7 +72,7 @@
};
const test = () => {
isVideo.value && talkCameraRef.value.switchCamera();
}
};
// 关闭销毁流程,逐层清理
const destroy = (type = 0) => {
if (type <= 0) {
@@ -184,19 +186,19 @@
destroy();
} else {
// 否则是未接通
noConnectDestroy()
noConnectDestroy();
}
} else if (error?.code === 1001) {
// 正常运行中后端掉线了
// onError({code:1001}); //处理逻辑和报错一样,直接调用报错方法
} else if (error?.code === 1011) {
// 服务器错误
onError({code:1011}); //处理逻辑和报错一样,直接调用报错方法
onError({ code: 1011 }); //处理逻辑和报错一样,直接调用报错方法
} else if (error?.code === 1000) {
// 用户主动挂断
} else if (error?.code === 4001) {
// 自定义异常挂断,参数错误
noConnectDestroy()
noConnectDestroy();
}
};
onLoad((e) => {
@@ -243,6 +245,61 @@
</script>
<style scoped lang="scss">
.isVideo {
// background-color: red;
.role-info {
display: none;
}
&.show-box {
background: linear-gradient(
120deg,
rgba(51, 51, 51, 0.2) 0%,
rgba(102, 102, 102, 0.2) 50%,
rgba(51, 51, 51, 0.2) 100%
);
backdrop-filter: blur(0px);
}
}
.title-time,
.title-tip {
text-align: center;
color: #fff;
line-height: 58rpx;
font-size: 38rpx;
margin: 0 auto;
}
.role-name {
margin-bottom: 26rpx;
}
.title-tip {
padding-right: 20rpx;
position: relative;
&::after {
content: '';
position: absolute;
animation: loading 2.5s infinite;
}
@keyframes loading {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
100% {
content: '';
}
}
}
.switch-div {
display: flex;
justify-content: flex-end;
@@ -250,6 +307,7 @@
margin-right: 20rpx;
margin-top: 10rpx;
width: 100%;
.switch-text {
font-weight: 400;
font-size: 28rpx;
@@ -285,10 +343,10 @@
max-height: 240rpx;
}
.show-box {
width: 100vw;
height: 100vh;
background: linear-gradient(120deg, #333333cc 0%, #666666cc 50%, #333333cc 100%);
backdrop-filter: blur(10px);
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
@@ -311,10 +369,6 @@
position: relative;
width: 100vw;
padding-top: 210rpx;
text-align: center;
color: #fff;
line-height: 58rpx;
font-size: 38rpx;
.role-avatar {
width: 185rpx;
@@ -326,42 +380,6 @@
transform: translateX(-50%);
top: 0;
}
.role-name {
margin-bottom: 26rpx;
}
.title-tip {
padding-right: 20rpx;
position: relative;
&::after {
content: '';
position: absolute;
animation: loading 2.5s infinite;
}
@keyframes loading {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
100% {
content: '';
}
}
}
.title-tip {
&:not(.connecting) {
// display: none;
}
}
}
.body-scene {
-147
View File
@@ -1,147 +0,0 @@
<template>
<view>
<video :is-live="true" id="myVideo" :src="videoSrc" :autoplay="true"></video>
<live-pusher id="livePusher" ref="livePusher" class="livePusher" url="rtmp://25.18.122.148:9605/voice_in/aa" mode="SD" :muted="true" :enable-camera="false" :auto-focus="true" :beauty="1" whiteness="2" aspect="9:16" @statechange="statechange" @netstatus="netstatus" @error="error"></live-pusher>
<view class="btn" @click="prepare"><text>预热</text></view>
<view class="btn" @click="start"><text>开始推流</text></view>
<view class="btn" @click="pause"><text>暂停推流</text></view>
<view class="btn" @click="resume"><text>恢复推流</text></view>
<view class="btn" @click="stop"><text>停止推流</text></view>
<view class="btn" @click="snapshot"><text>快照</text></view>
<view class="btn" @click="startPreview"><text>开启摄像头预览</text></view>
<view class="btn" @click="stopPreview"><text>关闭摄像头预览</text></view>
<view class="btn" @click="switchCamera"><text>切换摄像头</text></view>
</view>
</template>
<script>
import { prepareApi } from '@/api/talk.js'
var pusher
export default {
data() {
return {
context: null,
baseName: 'zhangsan'
}
},
computed: {
videoSrc() {
return 'rtmp://25.18.122.148:9605/voice_out/' + this.baseName
}
},
onReady() {
const videoContext = uni.createVideoContext('myVideo')
videoContext.setVolume(1.0)
// 注意:需要在onReady中 或 onLoad 延时
// pusher = uni.createLivePusherContext('livePusher', this)
// console.log(pusher)
},
methods: {
statechange(e) {
console.log('statechange:', e, JSON.stringify(e))
},
netstatus(e) {
console.log('netstatus:' + JSON.stringify(e))
},
error(e) {
console.log('error:' + JSON.stringify(e))
},
start: function () {
console.log('statechange:', pusher.start)
pusher.start({
success: a => {
console.log('livePusher.start:' + JSON.stringify(a))
}
})
},
prepare: function () {
prepareApi(this.baseName)
.then(res => {
console.log(res)
})
.catch(err => {
console.log(err)
if (err === 'prepare') {
this.initPusher()
}
})
},
close: function () {
pusher.close({
success: a => {
console.log('livePusher.close:' + JSON.stringify(a))
}
})
},
snapshot: function () {
pusher.snapshot({
success: e => {
console.log(JSON.stringify(e))
}
})
},
resume: function () {
pusher.resume({
success: a => {
console.log('livePusher.resume:' + JSON.stringify(a))
}
})
},
pause: function () {
pusher.pause({
success: a => {
console.log('livePusher.pause:' + JSON.stringify(a))
}
})
},
stop: function () {
pusher.stop({
success: a => {
console.log(JSON.stringify(a))
}
})
},
switchCamera: function () {
pusher.switchCamera({
success: a => {
console.log('livePusher.switchCamera:' + JSON.stringify(a))
}
})
},
startPreview: function () {
pusher.startPreview({
success: a => {
console.log('livePusher.startPreview:' + JSON.stringify(a))
}
})
},
stopPreview: function () {
pusher.stopPreview({
success: a => {
console.log('livePusher.stopPreview:' + JSON.stringify(a))
}
})
},
initPusher() {
console.log(1)
pusher = new plus.video.LivePusher('pusher-box', {
url: 'rtmp://25.18.122.148:9605/voice_in/' + this.baseName,
'enable-camera': false
})
pusher.addEventListener('statechange', this.statechange)
pusher.addEventListener('netstatus', this.netstatus)
pusher.addEventListener('error', this.error)
console.log(pusher)
}
}
}
</script>
<style>
.btn {
width: 100%;
height: 80rpx;
background-color: #ccc;
margin-bottom: 5rpx;
}
</style>
-182
View File
@@ -1,182 +0,0 @@
<template>
<view class="container">
<button @click="startPush" v-if="!isPushing">开始语音推流</button>
<button @click="stopPush" v-else>停止语音推流</button>
</view>
</template>
<script>
export default {
data() {
return {
isPushing: false, // 推流状态
pusher: null, // 推流实例
currentWebview: null // 当前 Webview 实例
}
},
onReady() {
// 改用 onReady(页面渲染完成) + 延迟
if (uni.getSystemInfoSync().platform === 'android') {
// 安卓端延迟 1000ms,且重试 3 次,确保获取到 Webview
let retryCount = 0
const getWebview = () => {
this.currentWebview = this.$mp.page.$getAppWebview()
if (this.currentWebview) {
console.log('Webview 获取成功:', this.currentWebview)
return
}
retryCount++
if (retryCount < 3) {
setTimeout(getWebview, 500) // 每次重试间隔 500ms
} else {
uni.showToast({ title: 'Webview 初始化失败', icon: 'none' })
}
}
setTimeout(getWebview, 1000) // 初始延迟 1 秒
}
},
onLoad() {
// // 仅 App 端初始化 Webview
// if (uni.getSystemInfoSync().platform !== 'app-plus') {
// uni.showToast({ title: '仅支持 App 端推流', icon: 'none' });
// return;
// }
// this.currentWebview = this.$mp.page.$getAppWebview();
},
onUnload() {
// 页面销毁时停止推流并销毁组件
this.stopPush()
},
methods: {
// 申请麦克风权限
async requestMicPermission() {
return new Promise((resolve, reject) => {
plus.android.requestPermissions(
['android.permission.RECORD_AUDIO'], // Android 权限
res => {
const granted = res.filter(item => item.granted).length === 1
granted ? resolve() : reject('麦克风权限申请失败')
},
err => reject(err)
)
// iOS 权限由系统自动弹窗,无需手动申请,此处可统一处理
})
},
async startPush() {
if (uni.getSystemInfoSync().platform !== 'app-plus') return
try {
await this.requestMicPermission()
// 优先用原生 API 获取 Webview(兜底)
this.currentWebview = plus.webview.currentWebview() || this.$mp.page.$getAppWebview()
if (!this.currentWebview) {
throw new Error('无法获取页面 Webview 实例')
}
// 后续创建 pusher + append 逻辑
this.pusher = plus.video.createLivePusher('', {
/* 配置 */
})
this.currentWebview.append(this.pusher) // 此时不会再为 null
// ... 启动推流
} catch (err) {
console.error('推流启动失败:', err)
}
},
// 开始推流
async startPush2() {
console.log('11', uni.getSystemInfoSync().platform)
// 1. 端判断
if (uni.getSystemInfoSync().platform !== 'android') return
console.log('22')
try {
// 2. 申请麦克风权限
// await this.requestMicPermission();
console.log('33')
// 3. 创建 LivePusher 实例(仅音频,隐藏组件)
this.pusher = plus.video.createLivePusher('', {
url: 'rtmp://25.18.122.148:9605/voice_in/aa', // 你的 RTMP 推流地址
top: '0px',
left: '0px',
width: '1px', // 隐藏组件(仅音频无需显示)
height: '1px',
position: 'static',
// 核心:仅音频配置
video: {
enable: false // 关闭视频采集
},
audio: {
enable: true, // 开启音频采集
sampleRate: 16000, // 采样率(AI 语音识别常用 16k)
bitrate: 64000, // 音频码率
codec: 'aac' // 编码格式(RTMP 主流)
},
// 推流模式:仅音频直播
mode: 'rtmp',
autopush: false // 不自动推流,手动控制
})
console.log('44')
// 4. 挂载到 Webview
this.currentWebview.append(this.pusher)
// 5. 监听推流状态
this.pusher.addEventListener('statechange', e => {
console.log('推流状态变化:', e)
switch (e.state) {
case 'connecting':
uni.showToast({ title: '正在连接推流服务器', icon: 'none' })
break
case 'connected':
this.isPushing = true
uni.showToast({ title: '推流成功', icon: 'success' })
break
case 'disconnected':
this.isPushing = false
uni.showToast({ title: '推流断开', icon: 'none' })
break
case 'error':
this.isPushing = false
uni.showToast({ title: `推流错误:${e.error}`, icon: 'none' })
break
}
})
// 6. 启动预览 + 推流
this.pusher.startPreview() // 启动音视频采集(必须调用)
this.pusher.start() // 开始推流
} catch (err) {
uni.showToast({ title: err, icon: 'none' })
console.error('推流启动失败:', err)
}
},
// 停止推流
stopPush() {
if (!this.pusher) return
// 停止推流 + 预览
this.pusher.stop()
this.pusher.stopPreview()
// 销毁组件
this.pusher.close()
this.pusher = null
this.isPushing = false
uni.showToast({ title: '推流已停止', icon: 'none' })
}
}
}
</script>
<style scoped>
.container {
padding: 20px;
}
button {
height: 80rpx;
line-height: 80rpx;
background: #007aff;
color: #fff;
border-radius: 10rpx;
margin-bottom: 20rpx;
}
</style>
-492
View File
@@ -1,492 +0,0 @@
<template>
<view>
<cached-avatar class="show-box-avatar"></cached-avatar>
<view class="show-box">
<nav-bar :is_seat="true"></nav-bar>
<view class="switch-div">
<view class="switch-text">场景提示</view>
<switch class="switch" :checked="!sceneDescShow" @click="switchChange" />
</view>
<view class="fl1-div-1"></view>
<view class="role-info">
<cached-avatar class="role-avatar"></cached-avatar>
<view class="role-name">{{ userInfo.userName }}</view>
<view class="title-time" v-if="isConnecting">{{ timeShow }}</view>
<view class="title-tip" v-if="!isConnecting">正在接通中</view>
</view>
<view class="fl1"></view>
<view class="body-scene" :class="{ show: sceneDescShow }">
{{ sceneDesc }}
</view>
<view class="close-btn" @click="closeTalking"></view>
</view>
<yao-RecordFrame
ref="recordFrame"
@onFrameRecorded="frameRecorded"
@currentDecibels="onCurrentDecibels"
@onStop="stopIt"
></yao-RecordFrame>
<view
ref="renderJSModule"
:pcmChunk="currentPCMChunk"
:change:pcmChunk="renderJS.appendPCMChunk"
:isStop="isStop"
:change:isStop="renderJS.stop"
type="renderjs"
module="renderJS"
></view>
</view>
</template>
<script>
import { getUserInfo } from '@/common/common';
import talkCom from './talkCom.nvue';
import { queryTraPartnerCharacterInfoById, partnerChatReportTrigger } from '@/api/sparring.js';
import common from '@/common/common';
import useWebSocket from './js/useWebSocket';
const { initConnection, ws } = useWebSocket();
import {
ProtocolCodec,
MessageType,
SerializationType,
CompressionType,
ControlCommand,
ProtocolConst
} from './js/ProtocolCodec';
import { getToken } from '@/common/common';
export default {
components: {
talkCom // 注册组件
},
data() {
return {
traId: '',
chaId: '',
talkingType: '',
execId: '',
isPreview: '',
chaName: '',
sceneDesc: '场景提示文案文儿提示文',
sceneDescShow: false,
timeShow: '11:19',
isConnecting: false, // 是否接通中
userInfo: {
userName: ''
},
ws: null,
wsUrl: 'wss://aitstest.jlbank.com.cn:7001/trapractice/voiceCallSocket/voiceCall?summary=' + getToken(), // 替换为实际后端地址,
// 播放组件
currentPCMChunk: null,
isStop: false // 停止播放指令
};
},
methods: {
callPhone() {
// 拨打电话
this.ws = uni.connectSocket({
url: this.wsUrl,
fail: () => {
console.log('fail');
},
success: () => {
console.log('web');
},
fail: () => {
console.log('fail');
}
});
this.ws.onOpen((res) => {
console.log('WebSocket连接已打开type', this.type);
this.ws.send({
data: ProtocolCodec.pack(MessageType.IDENTITY, {
traId: this.traId,
chaId: this.chaId,
type: this.type,
user_id: '1001',
token: 'your_auth_token',
name: '测试用户'
})
});
});
this.ws.onMessage((res) => {
const { msgType, body } = ProtocolCodec.unpack(res.data);
switch (msgType) {
case MessageType.IDENTITY:
console.log('身份校验成功', body);
this.onStartRecord();
this.isConnecting = true;
break;
case MessageType.AUDIO_DATA:
const buffer = body;
if (buffer.byteLength < 2) break;
const uint8 = new Uint8Array(buffer);
const numArray = Array.from(uint8);
this.currentPCMChunk = numArray;
break;
case MessageType.ERROR:
this.handleErrorMessage(body);
break;
default:
console.log('其他消息', body);
break;
}
});
},
handleErrorMessage(body) {
// 独立的错误处理函数
console.log(body);
},
disconnect() {
// 断开连接时候清理
// 清理音频相关资源
// this.onStopRecord();
// 重置状态
// this.currentPCMChunk = null;
// this.isConnecting = false;
},
manualDisconnect() {
// 主动挂断
// 标记主动挂断,跳过重连
this.isManualDisconnect = true;
this.disconnect();
this.handleFinalDisconnect();
this.isManualDisconnect = false;
},
startHeartbeat() {
// 先清除旧心跳
clearInterval(this.heartbeatTimer);
// 定时发送心跳包,检测后端是否存活
this.heartbeatTimer = setInterval(() => {
if (!this.isConnected) return;
// 发送心跳消息(根据你的协议定义,如MessageType.HEARTBEAT
this.sendMsg({
type: MessageType.HEARTBEAT,
body: { timestamp: Date.now() }
});
}, this.heartbeatInterval);
},
onStartRecord() {
console.log('开始录音');
try {
this.$refs.recordFrame.start({
sampleRate: 16000,
frameSize: 1024,
gain: 1.0,
onFrameRecorded: ({ isLastFrame, frameBuffer }) => {
// console.log('录音返回', frameBuffer);
try {
ws.value.send({
data: ProtocolCodec.pack(MessageType.AUDIO_DATA, frameBuffer)
});
} catch (e) {}
},
onDecibels: (decibels) => {
this.currentDecibels = decibels;
}
});
this.isRecording = true;
this.status = '录音中...';
} catch (e) {
console.error('启动录音失败:', e);
this.status = '启动录音失败';
this.isRecording = false;
}
},
// 接收音频帧并处理
frameRecorded({ isLastFrame, frameBuffer }) {
if (this.ws) {
try {
this.ws.send({
data: frameBuffer
});
} catch (e) {
console.error('发送音频帧失败:', e);
}
}
},
// 监听分贝值
onCurrentDecibels(decibels) {
// this.currentDecibels = decibels.toFixed(2);
// console.log("当前分贝:", this.currentDecibels);
},
// 录音停止回调
stopIt(base64) {
this.stopRecordAndClean();
console.log('录音停止,最终音频Base64', base64?.substring(0, 50) + '...');
},
// 关闭通话
closeTalking() {
// this.overTalking();
common.navigateBack();
},
// 结束通话生成报告
overTalking() {
partnerChatReportTrigger({
execId: this.execId,
traId: this.traId,
talkingType: this.isPreview ? 'preview' : 'practice'
}).then((res) => {
common.navigateTo(
'/pages/sparring/result?isOver=1&traId=' +
this.traId +
'&execId=' +
this.execId +
'&type=' +
this.type +
'&isPreview=' +
(this.isPreview ? '1' : '')
);
});
},
switchChange(e) {
this.sceneDescShow = !this.sceneDescShow;
this.isConnecting = !this.isConnecting;
},
// 停止录音并清理资源
stopRecordAndClean() {
if (this.isRecording) {
// 停止录音组件
this.$refs.recordFrame.stop();
this.isRecording = false;
this.status = '已停止录音';
}
// 关闭 WebSocket
if (this.ws) {
this.ws.close();
this.ws = null;
}
// 清理音频上下文
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
this.scriptProcessor = null;
this.frameBufferList = [];
}
// 重置状态
this.currentDecibels = 0;
}
},
onLoad(e) {
// 页面加载时初始化数据
this.traId = e.traId ?? '';
this.chaId = e.chaId ?? '';
this.type = e.type ?? '';
this.isPreview = e.isPreview === '1';
this.userInfo = getUserInfo();
// this.callPhone();
initConnection({ traId: this.traId, chaId: this.chaId, type: this.type }, this.onStartRecord);
},
onBackPress(res) {
return res.from === 'backbutton';
}
};
</script>
<script module="renderJS" lang="renderjs">
import { StreamPlayer } from "./js/StreamPlayer";
let player = null;
export default {
mounted() {
const _this = this;
player = new StreamPlayer({
inputSampleRate: 44100, // 后端PCM采样率(如8000/24000
numChannels: 1, // 声道数(单声道/双声道)
bitDepth: 16, // 位深(16/32bit
littleEndian: true, // 端序(和后端一致)
pcmType: 'int', // 类型(int/float
callback: _this.callback
});
},
methods: {
appendPCMChunk(numArray) {
// console.log('接收到', numArray.length);
if (!numArray || numArray.length === 0) return;
// 数字数组转回ArrayBuffer
const uint8 = new Uint8Array(numArray);
const arrayBuffer = uint8.buffer;
player.appendChunk(arrayBuffer);
},
// 停止播放(销毁播放器)
stop() {
if (player) {
player.destroy();
player = null;
// 重新初始化(方便后续再次播放)
const _this = this;
player = new StreamPlayer({
inputSampleRate: 16000,
numChannels: 1,
bitDepth: 16,
littleEndian: true,
pcmType: 'int',
callback: _this.callback
});
}
},
// 播放结束回调(通知主线程)
callback(e) {
if (e === 'ended') {
this.$ownerInstance.callMethod('changeStreamPlaying', { type: false });
}
}
}
};
</script>
<style scoped lang="scss">
.switch-div {
display: flex;
justify-content: flex-end;
align-items: center;
margin-right: 20rpx;
margin-top: 10rpx;
width: 100%;
.switch-text {
font-weight: 400;
font-size: 28rpx;
color: #ffffff;
}
.switch {
transform: scale(0.7) rotate(180deg);
:deep(.uni-switch-input) {
&.uni-switch-input-checked {
border-color: #9eacbf !important;
background-color: #9eacbf !important;
}
&:after {
top: 5px;
left: 5px;
width: 20px;
height: 20px;
}
&:before {
background-color: #62a1ff !important;
border-color: #62a1ff !important;
}
&:not(.uni-switch-input-checked) {
background-color: #62a1ff !important;
border-color: #62a1ff !important;
}
}
}
}
.fl1-div-1 {
min-height: 120rpx;
max-height: 240rpx;
}
.show-box {
width: 100vw;
height: 100vh;
background: linear-gradient(120deg, #333333cc 0%, #666666cc 50%, #333333cc 100%);
backdrop-filter: blur(10px);
position: fixed;
top: 0;
left: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
}
.show-box-avatar {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 900;
}
.role-info {
position: relative;
width: 100vw;
padding-top: 210rpx;
text-align: center;
color: #fff;
line-height: 58rpx;
font-size: 38rpx;
.role-avatar {
width: 185rpx;
height: 185rpx;
border-radius: 50%;
overflow: hidden;
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 0;
}
.role-name {
margin-bottom: 26rpx;
}
.title-tip {
padding-right: 20rpx;
position: relative;
&::after {
content: '';
position: absolute;
animation: loading 2.5s infinite;
}
@keyframes loading {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
100% {
content: '';
}
}
}
.title-tip {
&:not(.connecting) {
// display: none;
}
}
}
.body-scene {
overflow: auto;
width: calc(100% - 92rpx);
// margin: 46rpx;
padding: 26rpx;
color: rgba(255, 255, 255, 0.8);
border: 4rpx dashed rgba(255, 255, 255, 0.3);
border-radius: 16rpx;
opacity: 0;
transition: opacity 0.5s ease;
&.show {
opacity: 1;
}
}
.close-btn {
width: 118rpx;
height: 118rpx;
background-image: url(@/static/images/sparring/close-talk.png);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
border-radius: 50%;
margin-top: 46rpx;
margin-bottom: 112rpx;
flex-shrink: 0;
}
</style>
-158
View File
@@ -1,158 +0,0 @@
<template>
<video :is-live="true" :src="videoSrc" class="video-box" :autoplay="true" @error="error"></video>
<view class="hidden">
<live-pusher id="pusher-box" :enable-camera="false"></live-pusher>
<!-- <button class="btn" @click="prepare">预热</button>
<button class="btn" @click="start">开始推流1</button>
<button class="btn" @click="pause">暂停推流</button>
<button class="btn" @click="resume">恢复</button>
<button class="btn" @click="stop">停止推流</button> -->
<!-- <button class="btn" @click="snapshot">快照</button>
<button class="btn" @click="startPreview">开启摄像头预览1</button>
<button class="btn" @click="stopPreview">关闭摄像头预览</button>
<button class="btn" @click="switchCamera">切换摄像头</button> -->
<!-- <cover-view class="full-view"></cover-view> -->
</view>
</template>
<script>
import {
prepareApi
} from "@/api/talk.js";
import { onReady } from '@dcloudio/uni-app'
var pusher = null;
export default {
data() {
return {
baseName: 'wangwu',
context: null,
isGetting: true
}
},
computed: {
videoSrc() {
return 'rtmp://25.18.122.148:9605/voice_out/' + this.baseName;
},
},
mounted() {
// this.prepare()
},
beforeUnmount() {
this.isGetting = false
this.stop()
this.close()
},
methods: {
statechange(e) {
console.log('statechange:', e);
let {
detail
} = e
console.log(detail);
switch (detail.code) {
case '3004':
console.log(3004);
this.close()
break;
default:
break;
}
},
netstatus(e) {
console.log('netstatus');
},
error(e) {
console.log('error:', e);
},
start() {
console.log('start');
pusher.start(a => {
console.log('livePusher.start:');
}, b => {
console.log('livePusher.start.err:');
});
},
close() {
console.log('close');
pusher.close();
},
snapshot() {},
resume() {
pusher.resume();
},
pause() {
pusher.pause()
},
stop() {
pusher.stop()
},
switchCamera() {},
startPreview() {},
stopPreview() {},
prepare({traId, chaId, traTyp}) {
if(!this.isGetting){
this.isGetting = true
return
}
prepareApi({
traId: traId,
chaId: chaId,
traTyp: traTyp,
}).then(res => {
console.log(res)
if(res.body.prepare){
this.baseName = res.body.sid
this.initPusher()
}else{
setTimeout(()=> {
this.prepare({traId, chaId, traTyp})
},2000)
}
}).catch(err => {
console.log(err)
})
},
initPusher() {
console.log('rtmp://25.18.122.148:9605/voice_in/' + this.baseName)
pusher = new plus.video.LivePusher('pusher-box', {
url: 'rtmp://25.18.122.148:9605/voice_in/' + this.baseName,
"enable-camera": true,
"min-bitrate": 16,
"max-bitrate": 16,
top: '100px',
left: '0px',
width: '1px',
height: '1px',
position: 'static'
})
pusher.addEventListener('statechange', this.statechange)
pusher.addEventListener('netstatus', this.netstatus)
pusher.addEventListener('error', this.error)
console.log(pusher)
this.start()
}
}
}
</script>
<style>
.hidden{
width: 0px;
height: 0px;
overflow: hidden;
display: flex;
}
.full-view{
width: 200px;
height: 200px;
position: sticky;
flex: 1;
/* background-color: #fff; */
}
.pusher-box{
width: 1px;
height: 1px;
}
.video-box{
width: 1px;
height: 1px;
}
</style>