Web Speech API 实现语音识别
·
场景很简单,就是当用户长按按钮说话之后,可以将识别到的语音转为文字,效果图如下:
- 长按转换文字,断句动态拼接分隔符
- 识别的文字由浮层展示
- 光标在哪,语音插入到哪
- 支持中途编辑
- 清除文本和复制文本
- 长按按钮有动画效果
需要解决的核心问题:
- recognition.start() 不是立即开始录音,用户如果刚按下按钮就开始说话,很可能语音没有被录到,因此需要做一个延迟的处理。
- result.isFinal 这个方法只有在断句之前才进行一次拼接,就会给用户延迟显示文本的情况,我们需要 “边说边出字”
一起来看完整代码:
<template>
<div class="voice-wrapper">
<div class="input-container">
<textarea ref="textareaRef" v-model="displayText" class="result-box" placeholder="语音识别内容会显示在这里,可直接编辑"></textarea>
<tiny-icon-copy v-if="!copied" class="input-icon copy-icon" @click="handleCopy" />
<tiny-icon-yes v-else class="input-icon copy-icon" />
<tiny-icon-clear v-if="!deleted" class="input-icon clear-icon" @click="handleClear" />
<tiny-icon-yes v-else class="input-icon clear-icon" />
</div>
<div class="btn-container">
<div v-if="tempText" class="float-temp-text">
{{ tempText }}
</div>
<button id="recordBtn" :class="{ recording: isRecording }" @mousedown="startRecord" @touchstart.prevent="startRecord" @mouseup.stop="stopRecord" @touchend.stop="stopRecord">
<tiny-icon-mic class="mic-icon" />
</button>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted } from 'vue'
import { IconMic, IconCopySolid, IconRichTextDeleteTable, IconYes } from '@opentiny/vue-icon'
const TinyIconMic = IconMic()
const TinyIconYes = IconYes()
const TinyIconCopy = IconCopySolid()
const TinyIconClear = IconRichTextDeleteTable()
const textareaRef = ref(null)
const isRecording = ref(false)
const isPressing = ref(false)
const displayText = ref('')
const tempText = ref('')
const copied = ref(false)
const deleted = ref(false)
let copyTimer = null
let deleteTimer = null
let lastProcessedIndex = 0
const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition
const recognition = new Recognition()
recognition.lang = 'zh-CN'
recognition.continuous = true
recognition.interimResults = true
recognition.onresult = (e) => {
let finalChunk = ''
let interimChunk = ''
for (let i = lastProcessedIndex; i < e.results.length; i++) {
const result = e.results[i]
if (!result.isFinal) continue
const transcript = result[0].transcript.trim()
if (!transcript) continue
finalChunk += transcript
lastProcessedIndex = i + 1
}
const lastResult = e.results[e.results.length - 1]
if (lastResult && !lastResult.isFinal) {
interimChunk = lastResult[0].transcript.trim()
}
if (finalChunk) {
tempText.value = ''
insertAtCursor(finalChunk)
}
tempText.value = interimChunk
}
recognition.onaudiostart = () => {
if (isPressing.value) isRecording.value = true
else recognition.stop()
}
recognition.onend = () => {
if (isPressing.value) setTimeout(() => safeStart(), 50)
}
function insertAtCursor(text) {
const el = textareaRef.value
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const val = displayText.value
const before = val.substring(0, start)
const after = val.substring(end)
const isNewParagraph = start === 0
const lastChar = before.slice(-1)
const isAlreadyPunctuation = /[,。!?;:""''、]/g.test(lastChar)
const needComma = before && !isNewParagraph && !isAlreadyPunctuation
const insert = needComma ? ',' + text : text
displayText.value = before + insert + after
nextTick(() => {
const newPos = start + insert.length
el.setSelectionRange(newPos, newPos)
})
}
function safeStart() {
try { recognition.start() } catch {}
}
function startRecord(e) {
e?.preventDefault()
if (isPressing.value) return
lastProcessedIndex = 0
isPressing.value = true
safeStart()
}
function stopRecord() {
isPressing.value = false
if (isRecording.value) {
recognition.stop()
isRecording.value = false
setTimeout(() => tempText.value = '', 400)
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(displayText.value)
copied.value = true
clearTimeout(copyTimer)
copyTimer = setTimeout(() => copied.value = false, 2000)
} catch {}
}
function handleClear() {
deleted.value = true
clearTimeout(deleteTimer)
deleteTimer = setTimeout(() => deleted.value = false, 2000)
displayText.value = ''
}
onMounted(() => {
try { recognition.start(); recognition.stop() } catch {}
})
</script>
<style scoped>
.voice-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
background: #fff;
box-sizing: border-box;
}
.input-container {
position: relative;
flex: 1;
padding: 15px;
display: flex;
flex-direction: column;
min-height: 0;
}
.result-box {
flex: 1;
width: 100%;
padding: 12px;
font-size: 16px;
line-height: 1.6;
border: 1px solid #eee;
border-radius: 8px;
resize: none;
outline: none;
box-sizing: border-box;
overflow-y: auto;
}
.btn-container {
position: relative;
padding: 20px 0;
display: flex;
justify-content: center;
align-items: center;
border-top: 1px solid #f5f5f5;
background: #fff;
}
.float-temp-text {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 14px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
}
#recordBtn {
position: relative;
width: 68px;
height: 68px;
background: #42b983;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
}
#recordBtn.recording {
background: #2f9e6e;
transform: scale(1.08);
box-shadow: 0 0 0 6px rgba(66, 185, 131, 0.25);
}
#recordBtn.recording::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(66, 185, 131, 0.35);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
#recordBtn:active {
background: #359469;
}
.input-icon {
position: absolute;
font-size: 24px;
bottom: 25px;
fill: #323233;
cursor: pointer;
}
.copy-icon {
right: 60px;
}
.clear-icon {
right: 25px;
}
.mic-icon {
fill: #fff;
font-size: 30px;
}
</style>
更多推荐



所有评论(0)