Skip to content

实现 文字转语音 TTS 工具

分类:developer | 标签:TTS、语音、朗读 浏览器原生语音合成朗读文本

2.1 功能说明

浏览器原生语音合成朗读文本

核心能力

  • 待朗读文本
  • 音色
  • v.label
  • 语速
  • 音高
  • 音量

2.1.1 使用指南

文字转语音 TTS 使用说明

本工具调用浏览器内置的 Web Speech API(window.speechSynthesis)把文字朗读出来, 全部在本地完成,文本不会上传到任何服务器,也不消耗网络流量。

基本使用

1. 在“待朗读文本”中输入或粘贴文字,中英文混排均可。

2. 在“音色”下拉框中挑选一个语音。列表来自操作系统安装的语音包,不同系统差异很大:Windows 通常有中文“Huihui / Yaoyao”与多个英文音色,macOS 与 iOS 有“婷婷 / Meijia”等,Android 依赖 Google 语音服务。

3. 调整语速、音高、音量后点击“开始朗读”。朗读过程中可随时暂停、继续或停止。

参数含义

语速:0.5 为半速,1 为正常,2 为两倍速。中文播报建议 0.9 到 1.2 之间最自然。

音高:0 最低沉,1 为默认,2 最尖细。部分系统音色会忽略该参数。

音量:0 到 1,作用于本次朗读,不改变系统音量。

音色列表为空怎么办

语音包由系统异步加载,页面刚打开时列表可能是空的,等待一两秒或点击“刷新音色”即可。 如果始终为空,说明设备没有安装任何语音包:Windows 可在“设置 - 时间和语言 - 语音”中添加, Linux 桌面通常需要额外安装 speech-dispatcher 与语音数据。此时留空音色项,直接朗读会使用系统默认发音。

为什么不能导出音频文件

这是 Web Speech API 的设计限制,而不是本工具的缺失。合成后的声音由浏览器交给操作系统播放, 网页脚本拿不到对应的 PCM 数据,也无法接入 MediaRecorder 录制,所以任何纯前端 TTS 工具都无法直接产出音频文件。 需要成品音频时,请使用系统录音工具录制内录声音,或选择提供文件下载的服务端合成方案。

常见问题

点了没声音:部分浏览器要求先有用户交互才允许发声,请确认是通过点击按钮触发;同时检查系统音量与页面是否被静音。

长文本读到一半停止:某些浏览器对单条语音的长度有限制,建议把长文分成若干段分别朗读。

切换音色不生效:语音参数在朗读开始时锁定,请先停止再重新朗读。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • describeVoice()
  • loadVoices()
  • findVoiceObject()
  • stop()
  • speak()
  • togglePause()
  • copyText()
  • resetParams()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const supported = ref(false)
const text = ref('欢迎使用文字转语音工具,您可以在这里输入任意文字,选择合适的音色与语速,让浏览器把它朗读出来。')
const voices = ref([])
const voiceUri = ref('')
const rate = ref(1)
const pitch = ref(1)
const volume = ref(1)
const speaking = ref(false)
const paused = ref(false)
const error = ref('')

let synth = null
let utterance = null
let pollTimer = null

const charCount = computed(() => Array.from(text.value).length)

const canSpeak = computed(() => supported.value && text.value.trim().length > 0)

const estimateSeconds = computed(() => {
  // 中文约每秒 5 字,按语速折算,给出粗略估计
  const base = charCount.value / 5
  const s = base / (rate.value || 1)
  return s < 1 ? '1' : String(Math.round(s))
})

const statusText = computed(() => {
  if (!supported.value) return '不可用'
  if (paused.value) return '已暂停'
  if (speaking.value) return '朗读中'
  return '待机'
})

const statusType = computed(() => {
  if (!supported.value) return 'danger'
  if (paused.value) return 'warning'
  if (speaking.value) return 'success'
  return 'info'
})

function describeVoice(v) {
  const local = v.localService ? '本地' : '在线'
  const def = v.default ? ',默认' : ''
  return v.name + '(' + v.lang + ',' + local + def + ')'
}

function loadVoices() {
  if (!synth) return
  try {
    const list = synth.getVoices() || []
    voices.value = list.map((v) => ({
      voiceURI: v.voiceURI,
      name: v.name,
      lang: v.lang,
      label: describeVoice(v)
    }))
    // 默认优先选择中文音色
    if (!voiceUri.value && voices.value.length > 0) {
      const zh = voices.value.find((v) => /^zh/i.test(v.lang))
      voiceUri.value = zh ? zh.voiceURI : ''
    }
  } catch (e) {
    error.value = '读取音色列表失败:' + (e && e.message ? e.message : String(e))
  }
}

function findVoiceObject() {
  if (!voiceUri.value || !synth) return null
  try {
    const list = synth.getVoices() || []
    return list.find((v) => v.voiceURI === voiceUri.value) || null
  } catch (e) {
    return null
  }
}

function stop() {
  if (!synth) return
  try {
    synth.cancel()
  } catch (e) {
    // 忽略取消过程中的异常
  }
  speaking.value = false
  paused.value = false
}

function speak() {
  if (!canSpeak.value) return
  error.value = ''
  try {
    stop()
    utterance = new window.SpeechSynthesisUtterance(text.value)
    const v = findVoiceObject()
    if (v) {
      utterance.voice = v
      utterance.lang = v.lang
    }
    utterance.rate = rate.value
    utterance.pitch = pitch.value
    utterance.volume = volume.value
    utterance.onstart = () => {
      speaking.value = true
      paused.value = false
    }
    utterance.onend = () => {
      speaking.value = false
      paused.value = false
    }
    utterance.onerror = (ev) => {
      speaking.value = false
      paused.value = false
      // 用户主动取消也会触发 error,此处忽略 canceled/interrupted
      const reason = ev && ev.error ? ev.error : ''
      if (reason && reason !== 'canceled' && reason !== 'interrupted') {
        error.value = '朗读失败:' + reason
      }
    }
    synth.speak(utterance)
    speaking.value = true
  } catch (e) {
    speaking.value = false
    error.value = '朗读失败:' + (e && e.message ? e.message : String(e))
  }
}

function togglePause() {
  if (!synth || !speaking.value) return
  try {
    if (paused.value) {
      synth.resume()
      paused.value = false
    } else {
      synth.pause()
      paused.value = true
    }
  } catch (e) {
    error.value = '暂停或继续失败:' + (e && e.message ? e.message : String(e))
  }
}

async function copyText() {
  const content = text.value
  if (!content) return
  try {
    if (navigator.clipboard && window.isSecureContext) {
      await navigator.clipboard.writeText(content)
    } else {
      const ta = document.createElement('textarea')
      ta.value = content
      ta.style.position = 'fixed'
      ta.style.opacity = '0'
      document.body.appendChild(ta)
      ta.select()
      document.execCommand('copy')
      document.body.removeChild(ta)
    }
    error.value = ''
  } catch (e) {
    error.value = '复制失败,请手动选中文本复制'
  }
}

function resetParams() {
  rate.value = 1
  pitch.value = 1
  volume.value = 1
}

onMounted(() => {
  if (typeof window === 'undefined' || !window.speechSynthesis || !window.SpeechSynthesisUtterance) {
    supported.value = false
    return
  }
  supported.value = true
  synth = window.speechSynthesis
  loadVoices()
  try {
    synth.onvoiceschanged = loadVoices
  } catch (e) {
    // 部分浏览器不支持该事件,退化为轮询
  }
  // 兜底轮询:部分内核既不触发事件,首次读取也为空
  let tries = 0
  pollTimer = window.setInterval(() => {
    tries += 1
    if (voices.value.length > 0 || tries > 10) {
      window.clearInterval(pollTimer)
      pollTimer = null
      return
    }
    loadVoices()
  }, 500)
})

onBeforeUnmount(() => {
  if (pollTimer) {
    window.clearInterval(pollTimer)
    pollTimer = null
  }
  if (synth) {
    try {
      synth.onvoiceschanged = null
      synth.cancel()
    } catch (e) {
      // 忽略卸载时的异常
    }
  }
})

2.3 效果截图

文字转语音 TTS 效果截图

工具访问地址:https://www.i91tools.com/tools/text-to-speech