Files
aistream-test/测试流式传输uniapp/pages/index/index.vue
T
2025-11-27 20:48:18 +08:00

432 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="ai-chat-container">
<!-- 聊天消息列表 -->
<scroll-view class="chat-list" scroll-y scroll-bottom="{{scrollBottom}}"
:style="{height: `calc(100vh - ${inputAreaHeight}px)`}">
<!-- 系统消息 -->
<view class="chat-item system">
<view class="chat-avatar system-avatar">
<text class="iconfont icon-robot"></text>
</view>
<view class="chat-content">
<view class="content-text">你好我是智能助手有什么可以帮你的</view>
</view>
</view>
<!-- 用户消息 -->
<view class="chat-item user">
<view class="chat-content">
<view class="content-text">请问uniapp怎么实现语音流式传输</view>
</view>
<view class="chat-avatar user-avatar">
<text class="iconfont icon-user"></text>
</view>
</view>
<!-- AI回复消息 -->
<view class="chat-item ai">
<view class="chat-avatar ai-avatar">
<text class="iconfont icon-ai"></text>
</view>
<view class="chat-content">
<view class="content-text">
UniApp实现语音流式传输可通过5+ API采集音频帧结合WebSocket分块发送核心步骤包括
1. 初始化录音实例16k采样率单声道PCM);
2. 建立WebSocket连接
3. 分块发送音频数据
4. 接收实时ASR结果
</view>
</view>
</view>
</scroll-view>
<!-- 输入区域 + 按住发送按钮 -->
<view class="input-area" :style="{height: inputAreaHeight + 'px'}">
<!-- 按住发送按钮底部固定 -->
<view class="send-btn-area">
<button class="hold-send-btn" @touchstart="startRecord">
<text class="btn-text">{{status ? '松开发送' : '按住 说话/发送'}}</text>
</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
inputAreaHeight: 80,
status: "未录音",
recorder: null, // 录音实例
recordTimer: null, // 定时读取定时器
};
},
onUnload() {
// 页面卸载清理资源
this.stopRecord();
},
methods: {
// 申请录音权限
async applyRecordPermission() {
// const res = await uni.requestPermissions({ scope: "scope.record" });
return true; // 1 表示授权成功
},
// 开始录音
async startRecord() {
// 1. 导入Android原生类
const AudioRecord = plus.android.importClass("android.media.AudioRecord");
const AudioFormat = plus.android.importClass("android.media.AudioFormat");
const MediaRecorder = plus.android.importClass("android.media.MediaRecorder");
const Context = plus.android.importClass("android.content.Context");
const Activity = plus.android.runtimeMainActivity();
// 2. 录音参数配置(固定:16bit单声道,44.1kHz采样率)
const SAMPLE_RATE = 44100; // 采样率
const CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO; // 单声道
const AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT; // 16bit PCM
const BUFFER_SIZE = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT);
// 3. 创建AudioRecord实例(仅启动一次)
const audioRecord = new AudioRecord(
MediaRecorder.AudioSource.MIC, // 麦克风源
SAMPLE_RATE,
CHANNEL_CONFIG,
AUDIO_FORMAT,
BUFFER_SIZE * 2 // 缓冲区放大2倍,避免数据溢出
);
// 4. 初始化PCM数据缓冲区和切片定时器
const pcmDataList = []; // 临时存储实时PCM数据
let sliceTimer = null;
const SLICE_DURATION = 200; // 200ms切片
const SAMPLES_PER_SLICE = SAMPLE_RATE * (SLICE_DURATION / 1000); // 每切片的采样点数:44100*0.2=8820
// 5. 修复核心:创建Android short数组(替代废弃的plus.android.newArray
/**
* 兼容版创建Java short数组
* @param {number} length 数组长度
* @returns {JavaObject} Android short[] 对象
*/
function createShortArray(length) {
try {
// 方案1:新版API(推荐)
const ArrayClass = plus.android.importClass("java.lang.reflect.Array");
const ShortClass = plus.android.importClass("java.lang.Short").TYPE;
return ArrayClass.newInstance(ShortClass, length);
} catch (e) {
// 方案2:兼容旧版(备用)
const ShortArray = plus.android.createInstance("short[]", [length]);
return ShortArray;
}
}
// 6. 开始录音(仅一次启动)
audioRecord.startRecording();
console.log("Android原生录音启动,开始实时PCM捕获");
// 7. 持续读取PCM数据到内存(修复newArray报错)
const readPCMData = () => {
console.log('持续读取PCM数据到内存(修复newArray报错)', audioRecord.getRecordingState(), AudioRecord.RECORDSTATE_RECORDING);
if (!audioRecord || audioRecord.getRecordingState() !== AudioRecord.RECORDSTATE_RECORDING)
return;
// 修复:使用兼容版创建short数组
const shortArray = createShortArray(BUFFER_SIZE);
// 读取麦克风的PCM数据
const readSize = audioRecord.read(shortArray, 0, BUFFER_SIZE);
console.log('读取麦克风的PCM数据', readSize);
if (readSize > 0) {
// 将原生short数组转为JS数组(兼容写法)
const pcmChunk = [];
for (let i = 0; i < readSize; i++) {
try {
// 新版:通过get获取数组元素
pcmChunk.push(plus.android.getArrayElement(shortArray, i));
} catch (e) {
// 旧版:兼容getAttribute
pcmChunk.push(plus.android.getAttribute(shortArray, i));
}
}
pcmDataList.push(...pcmChunk); // 追加到总数据池
}
// 递归读取(保持实时性,避免阻塞)
setTimeout(readPCMData, 0); // 改用setTimeout,避免requestAnimationFrame的帧率限制
};
setTimeout(() => {
readPCMData();
}, 1000)
// 8. 每200ms截取一次PCM切片
sliceTimer = setInterval(() => {
// console.log(' 每200ms截取一次PCM切片',pcmDataList.length, SAMPLES_PER_SLICE);
if (pcmDataList.length >= SAMPLES_PER_SLICE) {
// 截取200ms对应的PCM数据(8820个采样点)
const slicePCM = pcmDataList.splice(0, SAMPLES_PER_SLICE);
// 处理切片(如封装为WAV、上传、分析等)
handlePCMSlice(slicePCM, SAMPLE_RATE);
console.log(`截取200ms PCM切片,采样点数:${slicePCM.length}`);
}
}, SLICE_DURATION);
// var r = plus.audio.getRecorder();
// // 初始化录音实例(WAV格式,含PCM原始数据)
// r.record( {filename:"_doc/audio/"}, function () {
// console.log( "Audio record success!" );
// }, function ( e ) {
// console.log( "Audio record failed: " + e.message );
// } );
// setTimeout(() => {
// r.stop();
// }, 2000)
},
// 停止录音
stopRecord() {
if (this.recorder) {
this.recorder.stop();
this.recorder = null;
}
if (this.recordTimer) {
clearInterval(this.recordTimer);
this.recordTimer = null;
}
this.status = "录音已停止";
}
}
};
</script>
<style scoped lang="scss">
// 全局容器
.ai-chat-container {
width: 100%;
height: 100vh;
background-color: #f5f7fa;
box-sizing: border-box;
padding-bottom: env(safe-area-inset-bottom); // 适配iOS底部安全区
}
// 聊天列表
.chat-list {
width: 100%;
padding: 20rpx 16rpx;
box-sizing: border-box;
// 消息项通用样式
.chat-item {
display: flex;
margin-bottom: 30rpx;
max-width: 85%;
// 头像通用样式
.chat-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 20rpx;
background-color: #fff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.iconfont {
font-size: 40rpx;
}
}
// 消息内容
.chat-content {
.content-text {
padding: 24rpx 28rpx;
border-radius: 20rpx;
line-height: 1.6;
font-size: 32rpx;
color: #333;
word-wrap: break-word;
word-break: break-all;
}
}
}
// 系统消息
.system {
margin: 0 auto 30rpx;
.chat-avatar {
background-color: #e8f4f8;
.iconfont {
color: #4299e1;
}
}
.chat-content {
.content-text {
background-color: #e8f4f8;
color: #2d3748;
border-radius: 16rpx;
}
}
}
// 用户消息(右对齐)
.user {
margin-left: auto;
flex-direction: row-reverse;
.chat-avatar {
margin-right: 0;
margin-left: 20rpx;
background-color: #4299e1;
.iconfont {
color: #fff;
}
}
.chat-content {
.content-text {
background-color: #4299e1;
color: #fff;
border-radius: 20rpx 20rpx 8rpx 20rpx;
}
}
}
// AI消息(左对齐)
.ai {
.chat-avatar {
background-color: #9f7aea;
.iconfont {
color: #fff;
}
}
.chat-content {
.content-text {
background-color: #fff;
border-radius: 20rpx 20rpx 20rpx 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
}
}
}
}
// 输入区域
.input-area {
width: 100%;
background-color: #fff;
padding: 16rpx 20rpx;
box-sizing: border-box;
border-top: 1rpx solid #eee;
// 输入框容器
.input-box {
position: relative;
width: 100%;
margin-bottom: 20rpx;
.input-text {
width: 100%;
min-height: 80rpx;
max-height: 200rpx;
padding: 20rpx 24rpx;
padding-right: 80rpx;
box-sizing: border-box;
border: 1rpx solid #e5e7eb;
border-radius: 40rpx;
font-size: 32rpx;
line-height: 1.5;
background-color: #f9fafb;
resize: none;
outline: none;
}
.clear-btn {
position: absolute;
right: 20rpx;
top: 50%;
transform: translateY(-50%);
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
.iconfont {
font-size: 32rpx;
color: #6b7280;
}
}
}
// 按住发送按钮区域
.send-btn-area {
width: 100%;
display: flex;
justify-content: center;
.hold-send-btn {
width: 80%;
height: 90rpx;
line-height: 90rpx;
border-radius: 45rpx;
background-color: #4299e1;
color: #fff;
font-size: 34rpx;
font-weight: 500;
box-shadow: 0 4rpx 12rpx rgba(66, 153, 225, 0.3);
transition: all 0.2s ease;
padding: 0;
margin: 0;
&:active {
background-color: #3182ce;
transform: scale(0.98);
}
.btn-text {
font-size: 34rpx;
}
}
}
}
// 图标占位样式(可替换为自定义图标/图片)
.iconfont {
font-family: "iconfont" !important;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-robot::before {
content: "\e600";
}
.icon-user::before {
content: "\e601";
}
.icon-ai::before {
content: "\e602";
}
.icon-clear::before {
content: "\e603";
}
</style>