432 lines
9.7 KiB
Vue
432 lines
9.7 KiB
Vue
|
|
<template>
|
|||
|
|
<view class="content">
|
|||
|
|
<view class="header">
|
|||
|
|
<text class="title">语音识别</text>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 状态显示区域 -->
|
|||
|
|
<view class="status-area">
|
|||
|
|
<view class="status-item">
|
|||
|
|
<text class="status-label">状态:</text>
|
|||
|
|
<text class="status-value" :class="{ 'status-ready': isReady, 'status-loading': isLoading }">{{ statusText }}</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 操作按钮区域 -->
|
|||
|
|
<view class="action-area">
|
|||
|
|
<button
|
|||
|
|
class="action-btn"
|
|||
|
|
:class="{ 'btn-recording': isRecording, 'btn-ready': !isRecording && isReady }"
|
|||
|
|
:disabled="isLoading || !isReady"
|
|||
|
|
@click="handleStart">
|
|||
|
|
<text class="btn-text">{{ isRecording ? '正在识别...' : '开始说话' }}</text>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
v-if="isRecording"
|
|||
|
|
class="action-btn btn-stop"
|
|||
|
|
@click="handleStop">
|
|||
|
|
<text class="btn-text">停止识别</text>
|
|||
|
|
</button>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<!-- 识别结果区域 -->
|
|||
|
|
<view class="result-area">
|
|||
|
|
<view class="result-title">
|
|||
|
|
<text>识别结果:</text>
|
|||
|
|
</view>
|
|||
|
|
<view class="result-content">
|
|||
|
|
<scroll-view class="result-scroll" scroll-y>
|
|||
|
|
<text class="result-text">{{ content }}</text>
|
|||
|
|
</scroll-view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|||
|
|
import { downloadModel, initVoskModel, startSpeechVoice, stopSpeechVoice } from '@/uni_modules/xwq-speech-to-text'
|
|||
|
|
|
|||
|
|
const modelPath = ref('') // 储存模型解压地址
|
|||
|
|
const content = ref('') // 识别结果
|
|||
|
|
const isReady = ref(false) // 模型是否已准备好
|
|||
|
|
const isRecording = ref(false) // 是否正在录音
|
|||
|
|
const isLoading = ref(false) // 是否正在加载/初始化
|
|||
|
|
const statusText = ref('准备中...')
|
|||
|
|
const serverUrl = ref('') // 服务器地址(可从配置文件或环境变量获取)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 页面加载时自动初始化
|
|||
|
|
*/
|
|||
|
|
onMounted(() => {
|
|||
|
|
// 从本地存储读取服务器地址(如果有配置的话)
|
|||
|
|
const savedServerUrl = uni.getStorageSync('vosk_server_url')
|
|||
|
|
if (savedServerUrl) {
|
|||
|
|
serverUrl.value = savedServerUrl
|
|||
|
|
}
|
|||
|
|
// 如果需要在代码中直接配置默认服务器地址,可以取消下面的注释
|
|||
|
|
// 如果没有配置,可以使用默认地址
|
|||
|
|
// if (!serverUrl.value) {
|
|||
|
|
// serverUrl.value = 'http://192.168.1.100:8080/vosk-model-small-cn-0.22.zip'
|
|||
|
|
// }
|
|||
|
|
autoInit()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 页面卸载前停止识别
|
|||
|
|
*/
|
|||
|
|
onBeforeUnmount(() => {
|
|||
|
|
if (isRecording.value) {
|
|||
|
|
stopSpeechVoice()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 自动初始化模型
|
|||
|
|
* 1. 先尝试使用已解压的模型(最快)
|
|||
|
|
* 2. 如果没有,尝试从服务器下载
|
|||
|
|
* 3. 如果服务器下载失败,从 static 目录初始化
|
|||
|
|
*/
|
|||
|
|
const autoInit = () => {
|
|||
|
|
isLoading.value = true
|
|||
|
|
statusText.value = '正在初始化模型...'
|
|||
|
|
|
|||
|
|
// 尝试从本地存储读取已解压的模型路径
|
|||
|
|
const savedModelPath = uni.getStorageSync('vosk_model_path')
|
|||
|
|
|
|||
|
|
if (savedModelPath) {
|
|||
|
|
// 使用已解压的模型(最快)
|
|||
|
|
statusText.value = '正在加载已解压模型...'
|
|||
|
|
initVoskModel({
|
|||
|
|
modelPath: savedModelPath,
|
|||
|
|
zipModelPath: ''
|
|||
|
|
}, (result) => {
|
|||
|
|
if (result && result.data) {
|
|||
|
|
modelPath.value = result.data.modelPath
|
|||
|
|
isReady.value = true
|
|||
|
|
isLoading.value = false
|
|||
|
|
statusText.value = '准备就绪,可以开始说话'
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '模型加载成功',
|
|||
|
|
icon: 'success',
|
|||
|
|
duration: 2000
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
// 加载失败,尝试从服务器下载
|
|||
|
|
downloadFromServer()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
// 没有已解压的模型,优先从服务器下载
|
|||
|
|
downloadFromServer()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从服务器下载模型
|
|||
|
|
*/
|
|||
|
|
const downloadFromServer = () => {
|
|||
|
|
// 如果配置了服务器地址,优先从服务器下载
|
|||
|
|
if (serverUrl.value) {
|
|||
|
|
statusText.value = '正在从服务器下载模型...'
|
|||
|
|
downloadModel(serverUrl.value, (res) => {
|
|||
|
|
console.log('服务器下载结果:', res)
|
|||
|
|
if (res.code == 0 && res.filePath != '') {
|
|||
|
|
// 下载成功,初始化模型
|
|||
|
|
statusText.value = '下载完成,正在初始化...'
|
|||
|
|
initVoskModel({
|
|||
|
|
zipModelPath: res.filePath
|
|||
|
|
}, (result) => {
|
|||
|
|
if (result && result.data) {
|
|||
|
|
modelPath.value = result.data.modelPath
|
|||
|
|
// 保存模型路径到本地存储
|
|||
|
|
uni.setStorageSync('vosk_model_path', result.data.modelPath)
|
|||
|
|
isReady.value = true
|
|||
|
|
isLoading.value = false
|
|||
|
|
statusText.value = '准备就绪,可以开始说话'
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '模型初始化成功',
|
|||
|
|
icon: 'success',
|
|||
|
|
duration: 2000
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
// 初始化失败,尝试从 static 加载
|
|||
|
|
initFromStatic()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
// 下载失败,尝试从 static 加载
|
|||
|
|
console.log('服务器下载失败,尝试从本地加载')
|
|||
|
|
initFromStatic()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
// 没有配置服务器地址,直接尝试从 static 加载
|
|||
|
|
console.log('未配置服务器地址,从本地加载')
|
|||
|
|
initFromStatic()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从 static 目录初始化模型
|
|||
|
|
*/
|
|||
|
|
const initFromStatic = () => {
|
|||
|
|
statusText.value = '正在解压模型文件...'
|
|||
|
|
let path = '/static/vosk-model-small-cn-0.22.zip'
|
|||
|
|
|
|||
|
|
// #ifdef APP-PLUS
|
|||
|
|
try {
|
|||
|
|
const staticPath = plus.io.convertLocalFileSystemURL(path)
|
|||
|
|
initVoskModel({
|
|||
|
|
zipModelPath: staticPath
|
|||
|
|
}, (result) => {
|
|||
|
|
if (result && result.data) {
|
|||
|
|
modelPath.value = result.data.modelPath
|
|||
|
|
// 保存模型路径到本地存储
|
|||
|
|
uni.setStorageSync('vosk_model_path', result.data.modelPath)
|
|||
|
|
isReady.value = true
|
|||
|
|
isLoading.value = false
|
|||
|
|
statusText.value = '准备就绪,可以开始说话'
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '模型初始化成功',
|
|||
|
|
icon: 'success',
|
|||
|
|
duration: 2000
|
|||
|
|
})
|
|||
|
|
} else {
|
|||
|
|
statusText.value = '初始化失败,请检查模型文件'
|
|||
|
|
isLoading.value = false
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '初始化失败',
|
|||
|
|
icon: 'error'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('初始化错误:', error)
|
|||
|
|
statusText.value = '初始化失败:' + error.message
|
|||
|
|
isLoading.value = false
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '初始化失败',
|
|||
|
|
icon: 'error'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
// #endif
|
|||
|
|
|
|||
|
|
// #ifndef APP-PLUS
|
|||
|
|
statusText.value = '仅支持 APP 端'
|
|||
|
|
isLoading.value = false
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '仅支持 APP 端',
|
|||
|
|
icon: 'error'
|
|||
|
|
})
|
|||
|
|
// #endif
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 开始识别
|
|||
|
|
*/
|
|||
|
|
const handleStart = () => {
|
|||
|
|
if (!isReady.value) {
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '模型未准备好,请稍候',
|
|||
|
|
icon: 'error'
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
isRecording.value = true
|
|||
|
|
statusText.value = '正在识别中...'
|
|||
|
|
content.value = '' // 清空上次识别结果
|
|||
|
|
|
|||
|
|
startSpeechVoice((res) => {
|
|||
|
|
console.log('识别结果===', res.data)
|
|||
|
|
if (res && res.data && res.data.text) {
|
|||
|
|
content.value += res.data.text + ' '
|
|||
|
|
// 滚动到底部
|
|||
|
|
uni.pageScrollTo({
|
|||
|
|
scrollTop: 10000,
|
|||
|
|
duration: 300
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 停止识别
|
|||
|
|
*/
|
|||
|
|
const handleStop = () => {
|
|||
|
|
stopSpeechVoice()
|
|||
|
|
isRecording.value = false
|
|||
|
|
statusText.value = '已停止识别'
|
|||
|
|
uni.showToast({
|
|||
|
|
title: '已停止识别',
|
|||
|
|
icon: 'success',
|
|||
|
|
duration: 1500
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style>
|
|||
|
|
.content {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
padding: 20rpx;
|
|||
|
|
min-height: 100vh;
|
|||
|
|
background: linear-gradient(to bottom, #667eea 0%, #764ba2 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
margin: 40rpx 0 60rpx 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title {
|
|||
|
|
font-size: 48rpx;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-area {
|
|||
|
|
background-color: rgba(255, 255, 255, 0.9);
|
|||
|
|
border-radius: 20rpx;
|
|||
|
|
padding: 30rpx;
|
|||
|
|
margin-bottom: 40rpx;
|
|||
|
|
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-label {
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
color: #666;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-value {
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
color: #333;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-ready {
|
|||
|
|
color: #52c41a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status-loading {
|
|||
|
|
color: #1890ff;
|
|||
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes pulse {
|
|||
|
|
0%, 100% {
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
50% {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-area {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
align-items: center;
|
|||
|
|
margin-bottom: 40rpx;
|
|||
|
|
gap: 30rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn {
|
|||
|
|
width: 500rpx;
|
|||
|
|
height: 500rpx;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
border: none;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
font-size: 36rpx;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #fff;
|
|||
|
|
box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.3);
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-ready {
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-ready:active {
|
|||
|
|
transform: scale(0.95);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-recording {
|
|||
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|||
|
|
animation: pulse-ring 1.5s ease-out infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes pulse-ring {
|
|||
|
|
0% {
|
|||
|
|
box-shadow: 0 0 0 0 rgba(245, 87, 108, 0.7);
|
|||
|
|
}
|
|||
|
|
50% {
|
|||
|
|
box-shadow: 0 0 0 40rpx rgba(245, 87, 108, 0);
|
|||
|
|
}
|
|||
|
|
100% {
|
|||
|
|
box-shadow: 0 0 0 0 rgba(245, 87, 108, 0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-stop {
|
|||
|
|
width: 300rpx;
|
|||
|
|
height: 100rpx;
|
|||
|
|
border-radius: 50rpx;
|
|||
|
|
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-text {
|
|||
|
|
font-size: 36rpx;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-area {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
background-color: rgba(255, 255, 255, 0.95);
|
|||
|
|
border-radius: 20rpx;
|
|||
|
|
padding: 30rpx;
|
|||
|
|
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
|
|||
|
|
min-height: 400rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-title {
|
|||
|
|
font-size: 32rpx;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #333;
|
|||
|
|
margin-bottom: 20rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-content {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-scroll {
|
|||
|
|
flex: 1;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.result-text {
|
|||
|
|
font-size: 30rpx;
|
|||
|
|
line-height: 1.8;
|
|||
|
|
color: #333;
|
|||
|
|
word-break: break-all;
|
|||
|
|
min-height: 200rpx;
|
|||
|
|
}
|
|||
|
|
</style>
|