guoyu/Test/yuyin/pages/speech/speech.vue
2025-12-03 18:58:36 +08:00

432 lines
9.7 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="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>