guoyu/Test/yuyin/pages/speech/speech.vue

432 lines
9.7 KiB
Vue
Raw Normal View History

2025-12-03 18:58:36 +08:00
<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>