392 lines
8.8 KiB
Vue
392 lines
8.8 KiB
Vue
<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>
|