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>
|