<template>
<view class="container">
<view class="status-bar">
<text class="status-text">{{ statusText }}</text>
</view>
<scroll-view class="transcript-area" scroll-y>
<text class="transcript-text">{{ transcript || '识别结果将显示在这里...' }}</text>
</scroll-view>
<view class="button-group">
<button class="action-btn start-btn" :disabled="isRecording" @click="startRecording">
开始录音
</button>
<button class="action-btn stop-btn" :disabled="!isRecording" @click="stopRecording">
停止录音
</button>
</view>
</view>
</template>
<script setup>
import {
ref,
onUnmounted
} from 'vue'
// ==================== 配置区域 ====================
// ⚠️ 安全提醒:生产环境请勿将 API Key 硬编码在前端。
// 建议由后端签发临时 Token(60秒有效),或通过 WebSocket 中转服务代理。
const CONFIG = {
// 替换为你的百炼 Workspace ID
workspaceId: 'llm-kivup',
// 替换为你的 API Key(仅用于测试,生产环境务必替换)
apiKey: 'sk-24d81b9',
// 模型名称
model: 'fun-asr-realtime'//-2026-02-28
}
// =================================================
// 响应式状态
const statusText = ref('准备就绪')
const transcript = ref('')
const isRecording = ref(false)
// 内部状态(非响应式)
let ws = null
let mediaStream = null
let audioContext = null
let sourceNode = null
let workletNode = null
let taskId = null
let isTaskStarted = false
let socketTask = null
// ==================== 工具函数 ====================
const generateTaskId = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
// 将 Float32 音频数据转换为 16-bit PCM 的 ArrayBuffer
const floatTo16BitPCM = (float32Array) => {
const buffer = new ArrayBuffer(float32Array.length * 2)
const view = new DataView(buffer)
for (let i = 0; i < float32Array.length; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
}
return buffer
}
// ==================== WebSocket 管理 ====================
const connectWebSocket = () => {
return new Promise((resolve, reject) => {
//?token=${CONFIG.apiKey}
const url = `wss://${CONFIG.workspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference`
// 使用 uni.connectSocket,明确传入 Authorization 头
socketTask = uni.connectSocket({
url: url,
header: {
// 关键:百炼要求 Bearer <api_key> 格式,注意大小写和空格
'Authorization': `Bearer ${CONFIG.apiKey}`
},
success: () => {
console.log('WebSocket 连接发起成功')
},
fail: (err) => {
console.error('WebSocket 连接失败', err)
statusText.value = '连接失败,请检查 API Key 和网络'
reject(err)
}
})
// 监听连接打开事件
uni.onSocketOpen(() => {
console.log('WebSocket 已连接')
resolve()
})
uni.onSocketError((err) => {
console.error('WebSocket 错误', err)
statusText.value = '连接错误,请检查鉴权信息'
console.log('ccccccccccccccccc',isTaskStarted)
reject(err)
})
uni.onSocketClose(() => {
console.log('WebSocket 已关闭')
isTaskStarted = false
})
uni.onSocketMessage((res) => {
try {
//console.log('bbbbbbbbbbbb',res.data)
const data = JSON.parse(res.data)
handleServerEvent(data)
} catch (e) {
// 忽略非 JSON 消息
}
})
})
}
// ==================== 服务端事件处理 ====================
const handleServerEvent = (data) => {
const header = data.header || {}
const payload = data.payload || {}
switch (header.event) {
case 'task-started':
console.log('任务已启动')
isTaskStarted = true
statusText.value = '正在识别...'
console.log('aaaaaaaaaa',isTaskStarted)
break
case 'result-generated':
// 处理识别结果
if (payload.output) {
const sentence = payload.output.sentence
if (sentence && sentence.text) {
// 如果是最终结果,追加到 transcript
console.log('ttttttttttt',sentence.text)
if (sentence.sentence_end) {
transcript.value += sentence.text
} else {
// 中间结果,可以实时显示(这里简单追加,实际可根据需求优化 UI)
// 为简化,这里仅在最终结果时追加
}
}
}
break
case 'task-finished':
console.log('任务已结束')
statusText.value = '识别完成'
isTaskStarted = false
break
case 'task-failed':
console.error('任务失败', payload)
statusText.value = `任务失败: ${payload.message || '未知错误'}`
isTaskStarted = false
break
default:
console.log('收到事件:', header.event, data)
}
}
// ==================== 发送指令 ====================
const sendRunTask = () => {
taskId = generateTaskId()
const message = {
header: {
action: 'run-task',
task_id: taskId,
streaming: 'duplex'
},
payload: {
task_group: 'audio',
task: 'asr',
function: 'recognition',
model: CONFIG.model,
parameters: {
format: 'pcm',
sample_rate: 16000
},
input: {}
}
}
//uni.sendSocketMessage(JSON.stringify(message))
uni.sendSocketMessage({
data: JSON.stringify(message),
fail(err) { console.error('send 失败', err) }
})
console.log('已发送 run-task:', message)
}
const sendFinishTask = () => {
if (ws && ws.readyState === WebSocket.OPEN && taskId) {
const message = {
header: {
action: 'finish-task',
task_id: taskId,
streaming: 'duplex'
},
payload: {}
}
//uni.sendSocketMessage(JSON.stringify(message))
uni.sendSocketMessage({
data: JSON.stringify(message),
fail(err) { console.error('send 失败', err) }
})
console.log('已发送 finish-task')
}
}
// ==================== 音频采集 ====================
let ar = null
const startAudioCapture = async () => {
try {
// 1. 引入原生插件(建议在 script 顶部统一声明,这里为聚焦方法内部逻辑)
ar = uni.requireNativePlugin("YL-AudioRecorder");
// 2. 配置参数:16kHz, 单声道, 16bit
// 这与你的 H5 版本参数一致,也是 STT 标准输入格式
ar.setConfig(16000, 1, 16);
// 3. 开始录音,并在回调中直接发送 PCM 数据
// 注意:App 端没有 isTaskStarted/ws 的浏览器对象,需用你自己的状态变量
/* ar.start((res) => {
// res.buffer 是 Uint8Array 类型的原始 PCM 帧
// 只有当 socket 连接就绪且任务已启动时才发送
//console.log('aaa',res.buffer.buffer)
console.log('aaab',isTaskStarted)
//console.log('aaab',socketTask)
if (!res.buffer || !isTaskStarted || !socketTask) return;
// 关键:发送底层的 ArrayBuffer,而不是 Uint8Array 本身
// socketTask.send 在 App 端支持 ArrayBuffer
console.log('aaa',res.buffer.buffer)
socketTask.send({
data: res.buffer.buffer
});
}); */
ar.start((res) => {
console.log('res.buffer',!res.buffer);
console.log('isTaskStarted',!isTaskStarted);
if (!res.buffer || !isTaskStarted) return;
let data = res.buffer;
if (data instanceof Uint8Array) {
// TypedArray.slice() 会拷贝出一段独立的内存,避免和插件的内存池竞争
data = data.slice().buffer;
}
socketTask.send({
data,
fail(err) { console.error('PCM 发送失败:', err); }
});
});
console.log('音频采集已启动 (YL-AudioRecorder)');
} catch (err) {
console.error('获取麦克风权限失败:', err);
statusText.value = '无法访问麦克风,请检查权限';
throw err;
}
};
const stopAudioCapture = () => {
ar.stop(); // 停止录音
console.log('音频采集已停止')
}
// ==================== 对外方法 ====================
const startRecording = async () => {
if (isRecording.value) return
try {
statusText.value = '正在连接...'
transcript.value = ''
// 1. 建立 WebSocket 连接
await connectWebSocket()
// 2. 发送 run-task 指令
sendRunTask()
// 3. 等待 task-started 事件后开始采集音频
// 这里简单等待一下,实际应通过事件回调控制
await new Promise(resolve => setTimeout(resolve, 1000))
// 4. 启动音频采集
await startAudioCapture()
isRecording.value = true
statusText.value = '正在录音...'
} catch (err) {
console.error('启动失败:', err)
statusText.value = '启动失败,请重试'
cleanup()
}
}
const stopRecording = () => {
if (!isRecording.value) return
isRecording.value = false
statusText.value = '正在完成...'
// 1. 停止音频采集
stopAudioCapture()
// 2. 发送 finish-task
sendFinishTask()
cleanup()
}
const cleanup = () => {
stopAudioCapture()
// 正常关闭
socketTask.close({
code: 1000, // 1000 表示正常关闭,默认值
reason: '用户停止录音', // 可选,关闭原因
success: () => {
console.log('WebSocket 已关闭')
},
fail: (err) => {
console.error('关闭失败', err)
}
})
socketTask = null;
isRecording.value = false
isTaskStarted = false
}
// 组件卸载时清理资源
onUnmounted(() => {
cleanup()
})
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 20rpx;
box-sizing: border-box;
background-color: #f5f5f5;
}
.status-bar {
padding: 20rpx;
background-color: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.status-text {
font-size: 28rpx;
color: #666;
}
.transcript-area {
flex: 1;
background-color: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-sizing: border-box;
}
.transcript-text {
font-size: 32rpx;
line-height: 1.6;
color: #333;
word-break: break-all;
}
.button-group {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 12rpx;
font-size: 32rpx;
border: none;
}
.start-btn {
background-color: #07c160;
color: #fff;
}
.start-btn:disabled {
background-color: #9edba9;
color: #fff;
}
.stop-btn {
background-color: #ee0a24;
color: #fff;
}
.stop-btn:disabled {
background-color: #f5a3a8;
color: #fff;
}
</style>

识别错别字比较多