JS朗读功能的实现

308 阅读3分钟

JS朗读功能踩坑记录

API SpeechSynthesisUtterance

-   `lang` : 语言 Gets and sets the language of the utterance.
-   `pitch` : 音高 Gets and sets the pitch at which the utterance will be spoken at.
-   `rate` : 语速 Gets and sets the speed at which the utterance will be spoken at.
-   `text` : 文本 Gets and sets the text that will be synthesised when the utterance is spoken.
-   `voice` : 声音 Gets and sets the voice that will be used to speak the utterance.
-   `volume` : 音量 Gets and sets the volume that the utterance will be spoken at.
-   `onboundary` : 单词或句子边界触发,即分隔处触发 Fired when the spoken utterance reaches a word or sentence boundary.
-   `onend` : 结束时触发 Fired when the utterance has finished being spoken.
-   `onerror` : 错误时触发 Fired when an error occurs that prevents the utterance from being succesfully spoken.
-   `onmark` : Fired when the spoken utterance reaches a named SSML "mark" tag.
-   `onpause` : 暂停时触发 Fired when the utterance is paused part way through.
-   `onresume` : 重新播放时触发 Fired when a paused utterance is resumed.
-   `onstart` : 开始时触发 Fired when the utterance has begun to be spoken.

API SpeechSynthesis

-   `paused` : **Read only** 是否暂停 A Boolean that returns true if the SpeechSynthesis object is in a paused state.
-   `pending` : **Read only** 是否处理中 A Boolean that returns true if the utterance queue contains as-yet-unspoken utterances.
-   `speaking` : **Read only** 是否朗读中 A Boolean that returns true if an utterance is currently in the process of being spoken — even if SpeechSynthesis is in a paused state.
-   `onvoiceschanged` : 声音变化时触发
-   `cancel()` : 情况待朗读队列 Removes all utterances from the utterance queue.
-   `getVoices()` : 获取浏览器支持的语音包列表 Returns a list of SpeechSynthesisVoice objects representing all the available voices on the current device.
-   `pause()` : 暂停 Puts the SpeechSynthesis object into a paused state.
-   `resume()` : 重新开始 Puts the SpeechSynthesis object into a non-paused state: resumes it if it was already paused.
-   `speak()` : 读合成的语音,参数必须为`SpeechSynthesisUtterance`的实例 Adds an utterance to the utterance queue; it will be spoken when any other utterances queued before it have been spoken.

简单实现

const speaker = new window.SpeechSynthesisUtterance();
speaker.text="请朗读这段文字的内容!"
window.speechSynthesis.speak('speaker')

踩坑记录

  • 如何指定语言朗读
const speech = new SpeechSynthesisUtterance()
let language =lang ||(config?.language === Language.Auto ? window.navigator.language : userConfig?.language)
// 特殊处理
if (language === 'ja') {
     language = 'ja-JP'
} else if (language === 'zh-Hans') {
     language = 'zh-CN'
} else if (language === 'zh-Hant') {
     language = 'zh-TW'
}
// chrome 使用google 语言包最稳定
const voiceName = isEdge ? 'Microsoft' : isChrome ? 'Google' : 'Google'
const voices = window.speechSynthesis.getVoices()
const hasVoices = voices.find((item) => {
   const name = item.name
   return name.includes(voiceName) &&item.lang.toUpperCase().includes(language.toUpperCase())
 })
if (hasVoices) {
   speech.voice = hasVoices
} else {
  speech.voice = voices[0]
 }
  • 如何处理朗读外语中断(韩语,日语,俄语,未解决)
    • 将朗读文字根据标点符号分割 (朗读依旧会中断)
    • 将朗读文字分割为100个字符的文字 (英语不好配置)
    • 怀疑是特殊字符导致(去除特殊字符后依旧朗读失败)

完整代码

import { Language, UserConfig, getUserConfig } from '@/config'
import { memo, useEffect, useRef, useState } from 'react'
import { VscDebugContinueSmall, VscDebugStart, VscDebugPause, VscClose } from 'react-icons/vsc'
import { isMac, isChrome, isEdge } from '@/utils/utils'
import { debounce } from 'lodash-es'

interface ReadAloudType {
  text: string
  lang?: string
}

const ReadAloud = ({ text, lang }: ReadAloudType) => {
  const [isReading, setIsReading] = useState(false)
  const [isPause, setIsPause] = useState(false)
  const [config, setConfing] = useState<UserConfig>()
  const speechRef = useRef<any>([])

  const play = debounce(async () => {
    window.speechSynthesis.cancel()
    setIsReading(true)
    speechRef.current.forEach((res) => {
      window.speechSynthesis.speak(res)
    })
  }, 1000)

  const pause = () => {
    setIsPause(true)
    window.speechSynthesis.pause()
  }

  const resume = () => {
    setIsPause(false)
    window.speechSynthesis.resume()
  }

  const cancel = () => {
    setIsReading(false)
    window.speechSynthesis.cancel()
  }

  const splitSentences = (text: string) => {
    let sentences = text.split(/([.!?。!?])/g)
    sentences = sentences.filter(function (sentence) {
      return sentence.trim().length > 0
    })
    let result:string[] = []
    let currentSentence = ''
    for (let i = 0; i < sentences.length; i++) {
      if ((currentSentence + sentences[i]).length <= 120) {
        currentSentence += sentences[i]
      } else {
        let temp: string = currentSentence.trim()
        while (temp.length > 120) {
          result.push(temp.substring(0, 120))
          temp = temp.substring(120)
        }

        result.push(temp)
        currentSentence = sentences[i]
      }
    }
    if (currentSentence !== '') {
      result.push(currentSentence)
    }
    return result
  }

  const init = async () => {
    const userConfig = await getUserConfig()
    let language =
      lang ||
      (config?.language === Language.Auto ? window.navigator.language : userConfig?.language)
    setConfing(userConfig)
    const unescapedText = text.replace(/\\(.)/g, (match, escapedChar) => {
      const unescapedCharMap = {
        n: '\n',
        r: '\r',
        t: '\t',
        f: '\f',
        b: '\b',
        "'": "'",
        '"': '"',
        '\\': '\\',
      }

      return unescapedCharMap[escapedChar] || escapedChar
    })
    const textList = splitSentences(unescapedText)
    for (let i = 0; i < textList?.length; i++) {
      const textItem = textList[i]
      const speechList = speechRef.current
      const speech = new SpeechSynthesisUtterance()
      speech.text = textItem
        .replace(/(\*{1,2}|#{1,6}|[`~]{1,2})(.*?)(\1)/g, '$2')
        .replace(/\[.*?\]\(.*?\)/g, '')

      // 特殊处理
      if (language === 'ja') {
        language = 'ja-JP'
      } else if (language === 'zh-Hans') {
        language = 'zh-CN'
      } else if (language === 'zh-Hant') {
        language = 'zh-TW'
      }

      const voiceName = isEdge ? 'Microsoft' : isChrome ? 'Google' : 'Google'
      const voices = window.speechSynthesis.getVoices()
      const hasVoices = voices.find((item) => {
        const name = item.name
        return name.includes(voiceName) && item.lang.toUpperCase().includes(language.toUpperCase())
      })
      if (hasVoices) {
        speech.voice = hasVoices
      } else {
        speech.voice = voices[0]
      }
      // 最后一句添加结束事件
      if (i === textList.length - 1) {
        speech.onend = (e) => {
          console.log('🚀 ~ file: ReadAloud.tsx:85 ~ init ~ onend:')
          setIsReading(false)
        }
      }

      speech.onerror = (error) => {
        console.log('🚀 ~ file: ReadAloud.tsx:39 ~ play ~ error:', error)
        setIsPause(false)
        setIsReading(false)
      }

      speech.onstart = (e) => {
        console.log('🚀 ~ file: ReadAloud.tsx:39 ~ play ~ start:', e)
      }

      speech.onpause = () => {
        setIsPause(true)
      }
      speechList.push(speech)
      console.log('🚀 ~ file: ReadAloud.tsx:106 ~ init ~ speech:', speech, speechList)
      speechRef.current = speechList
    }
  }

  useEffect(() => {
    if (text.length > 0) {
      init()
    }
    return () => {
      window.speechSynthesis.cancel()
    }
  }, [text])

  return (
    <div className="glarity---flex glarity---items-center">
      {!isReading && (
        <div
          onClick={() => {
            play()
          }}
          className="glarity---cursor-pointer glarity---flex glarity---items-center"
          title="Read"
        >
          <VscDebugStart size={16} />
        </div>
      )}

      {isReading && !isPause && (
        <div
          onClick={pause}
          className="glarity---cursor-pointer glarity---flex glarity---items-center"
          title="Pause"
        >
          <VscDebugPause size={16} />
        </div>
      )}
      {isReading && isPause && (
        <div
          onClick={resume}
          className="glarity---cursor-pointer glarity---flex glarity---items-center"
          title="Resume"
        >
          <VscDebugContinueSmall size={16} />
        </div>
      )}
      {isReading && (
        <div
          onClick={cancel}
          className="glarity---cursor-pointer glarity---flex glarity---items-center"
          title="Cancel"
        >
          <VscClose size={16} />
        </div>
      )}
    </div>
  )
}

export default memo(ReadAloud)