场景很简单,就是当用户长按按钮说话之后,可以将识别到的语音转为文字,效果图如下:

  • 长按转换文字,断句动态拼接分隔符
  • 识别的文字由浮层展示
  • 光标在哪,语音插入到哪
  • 支持中途编辑
  • 清除文本和复制文本
  • 长按按钮有动画效果

需要解决的核心问题:

  • 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>
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐