Appearance
实现 文字转语音 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-input、el-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 效果截图
