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

392 lines
8.8 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">VOSK 离线语音识别</text>
</view>
<!-- 服务器配置区域 -->
<view class="config-area">
<view class="config-title">
<text>模型服务器配置</text>
</view>
<view class="config-item">
<text class="config-label">服务器地址</text>
<input
class="config-input"
v-model="serverUrl"
placeholder="例如http://192.168.1.100:8080/vosk-model-small-cn-0.22.zip"
@blur="saveServerConfig"
/>
</view>
<view class="config-tips">
<text class="tips-text">💡 提示:配置服务器地址后,"开始说话"功能会优先从服务器下载模型</text>
<text class="tips-text">📋 步骤1. 双击运行"启动服务器.bat" 2. 复制显示的局域网地址 3. 粘贴到上方输入框</text>
</view>
</view>
<view class="button-area">
<button type="primary" size="large" @click="goToSpeech" class="go-speech-btn">开始说话</button>
<view class="divider"></view>
<button type="primary" @click="downModel" :disabled="downloading">在线下载模型</button>
<button type="primary" @click="init" :disabled="initializing">初始化模型</button>
<button type="primary" @click="unzipInnit" :disabled="!modelPath">使用已解压模型</button>
<button type="success" @click="start" :disabled="!isModelReady || isRecording">开始识别</button>
<button type="warn" @click="stop" :disabled="!isRecording">停止识别</button>
</view>
<view class="status-area">
<text class="status-text">状态{{ statusText }}</text>
<text class="model-path" v-if="modelPath">模型路径{{ modelPath }}</text>
</view>
<view class="result-area">
<view class="result-title">
<text>识别结果</text>
</view>
<view class="result-content">
<textarea :value="content" disabled class="textarea"></textarea>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { downloadModel, initVoskModel, startSpeechVoice, stopSpeechVoice } from '@/uni_modules/xwq-speech-to-text'
// 定义本地类型(因为 uni-app 无法直接从 .uts 文件导入类型)
// CallbackType: 初始化回调类型
// RedultType: 识别结果类型
// DownloadBackInfo: 下载回调类型
const modelPath = ref('') // 储存模型解压地址
const content = ref('') // 识别结果
const isModelReady = ref(false) // 模型是否已初始化
const isRecording = ref(false) // 是否正在录音
const downloading = ref(false) // 是否正在下载
const initializing = ref(false) // 是否正在初始化
const statusText = ref('未初始化')
const serverUrl = ref('') // 服务器地址
/**
* 页面加载时读取保存的服务器地址
*/
onMounted(() => {
const savedServerUrl = uni.getStorageSync('vosk_server_url')
if (savedServerUrl) {
serverUrl.value = savedServerUrl
}
// 如果没有配置服务器地址,可以在这里设置默认值
// 注意:将 192.168.1.100 替换为你的实际局域网 IP 地址
// if (!serverUrl.value) {
// serverUrl.value = 'http://192.168.1.100:8080/vosk-model-small-cn-0.22.zip'
// }
})
/**
* 保存服务器配置
*/
const saveServerConfig = () => {
if (serverUrl.value) {
uni.setStorageSync('vosk_server_url', serverUrl.value)
uni.showToast({
title: '配置已保存',
icon: 'success',
duration: 1500
})
}
}
/**
* 跳转到语音识别页面
*/
const goToSpeech = () => {
uni.navigateTo({
url: '/pages/speech/speech'
})
}
/**
* 在线下载模型
*/
const downModel = () => {
downloading.value = true
statusText.value = '正在下载模型...'
// 注意:需要替换为真实的模型下载地址
const url = 'https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip'
downloadModel(url, (res) => {
console.log('下载结果:', res)
downloading.value = false
if (res.code == 0 && res.filePath != '') {
statusText.value = '下载完成,正在初始化...'
initVoskModel({
zipModelPath: res.filePath
}, (result) => {
console.log('模型地址====', result.data)
if (result && result.data) {
modelPath.value = result.data.modelPath
isModelReady.value = true
statusText.value = '模型初始化成功'
uni.showToast({
title: '模型初始化成功',
icon: 'success'
})
}
})
} else {
statusText.value = '下载失败'
uni.showToast({
title: '下载失败',
icon: 'error'
})
}
})
}
/**
* 初始化模型(从 static 目录)
* 将返回的模型地址缓存起来,在第二次用到的时候直接传入初始化,避免重复解压
*/
const init = () => {
initializing.value = true
statusText.value = '正在初始化模型...'
let path = '/static/vosk-model-small-cn-0.22.zip'
// #ifdef APP-PLUS
const staticPath = plus.io.convertLocalFileSystemURL(path)
initVoskModel({
zipModelPath: staticPath
}, (result) => {
console.log('模型地址====', result.data)
if (result && result.data) {
modelPath.value = result.data.modelPath
isModelReady.value = true
initializing.value = false
statusText.value = '模型初始化成功'
uni.showToast({
title: '模型初始化成功',
icon: 'success'
})
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({
title: '仅支持 APP 端',
icon: 'error'
})
initializing.value = false
// #endif
}
/**
* 存在模型解压路径(即已经初始化过一次)
*/
const unzipInnit = () => {
if (!modelPath.value) {
uni.showToast({
title: '请先初始化模型',
icon: 'error'
})
return
}
initializing.value = true
statusText.value = '正在加载已解压模型...'
initVoskModel({
modelPath: modelPath.value,
zipModelPath: ''
}, (result) => {
console.log('模型地址====', result.data)
if (result && result.data) {
isModelReady.value = true
initializing.value = false
statusText.value = '模型加载成功'
uni.showToast({
title: '模型加载成功',
icon: 'success'
})
}
})
}
/**
* 开始语音识别
*/
const start = () => {
if (!isModelReady.value) {
uni.showToast({
title: '请先初始化模型',
icon: 'error'
})
return
}
isRecording.value = true
statusText.value = '正在录音识别...'
startSpeechVoice((res) => {
console.log('识别结果===', res.data)
if (res && res.data && res.data.text) {
content.value += res.data.text + ' '
}
})
}
/**
* 停止语音识别
*/
const stop = () => {
stopSpeechVoice()
isRecording.value = false
statusText.value = '已停止识别'
uni.showToast({
title: '已停止识别',
icon: 'success'
})
}
</script>
<style>
.content {
display: flex;
flex-direction: column;
padding: 20rpx;
min-height: 100vh;
background-color: #f5f5f5;
}
.header {
display: flex;
justify-content: center;
margin: 40rpx 0;
}
.title {
font-size: 40rpx;
font-weight: bold;
color: #333;
}
.button-area {
display: flex;
flex-direction: column;
gap: 20rpx;
margin: 20rpx 0;
}
.button-area button {
margin: 0;
}
.go-speech-btn {
margin-bottom: 10rpx !important;
height: 100rpx;
font-size: 36rpx;
font-weight: bold;
}
.divider {
height: 1px;
background-color: #e0e0e0;
margin: 30rpx 0;
}
.config-area {
background-color: #fff;
border-radius: 10rpx;
padding: 20rpx;
margin: 20rpx 0;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}
.config-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.config-item {
display: flex;
flex-direction: column;
margin-bottom: 15rpx;
}
.config-label {
font-size: 28rpx;
color: #666;
margin-bottom: 10rpx;
}
.config-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
background-color: #f9f9f9;
box-sizing: border-box;
}
.config-tips {
margin-top: 15rpx;
padding: 15rpx;
background-color: #f0f7ff;
border-radius: 8rpx;
}
.tips-text {
font-size: 24rpx;
color: #1890ff;
line-height: 1.5;
}
.status-area {
display: flex;
flex-direction: column;
padding: 20rpx;
background-color: #fff;
border-radius: 10rpx;
margin: 20rpx 0;
}
.status-text {
font-size: 28rpx;
color: #666;
margin-bottom: 10rpx;
}
.model-path {
font-size: 24rpx;
color: #999;
word-break: break-all;
}
.result-area {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 20rpx;
}
.result-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.result-content {
flex: 1;
background-color: #fff;
border-radius: 10rpx;
padding: 20rpx;
}
.textarea {
width: 100%;
min-height: 400rpx;
font-size: 28rpx;
line-height: 1.6;
color: #333;
}
</style>