把文字变成声音——前端 TTS 技术简史与落地笔记

一、从“说话”到“跑在浏览器里”

文本转语音(TTS)有三条技术主线:

  1. 拼接法:把录好的语料切片拼起来,音质最好,体积最大;
  2. 参数法:用声码器(STRAIGHT、WORLD)压缩特征,体积小,机械感重;
  3. 神经网络:Tacotron2、FastSpeech2、VITS 端到端,自然度逼近真人,计算量也最大。

浏览器原生只给了一个“黑盒”——speechSynthesis,底层各平台实现参差不齐:Win10 用 OneCore、macOS/iOS 用 AVSpeech、Android 用 Google TTS。好处是零成本,坏处是“能响就行”,不可定制、不可路由、不可离线。


二、Web Speech API 的“三不能”

  1. 不能指定输出设备:默认走系统扬声器,<audio>setSinkId 都不给用;
  2. 不能接进 Web Audio:destinationNode 永远收不到声音,于是“边读边录”成了幻觉;
  3. 不能离线缓存:语音每次都是实时请求,断网就哑。

因此,做“网页配音”可以,做“严肃业务”得另想办法。
写了个示例代码,直接拿去:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>语音历史记录示例</title>
  <style>
    .history-container {
      max-height: 300px;
      overflow-y: auto;
      border: 1px solid #ddd;
      padding: 10px;
      margin-top: 20px;
    }
    .history-item {
      padding: 8px;
      border-bottom: 1px solid #eee;
      cursor: pointer;
      transition: background-color 0.2s;
    }
    .history-item:hover {
      background-color: #f0f0f0;
    }
    .controls {
      margin: 15px 0;
    }
  </style>
</head>
<body>
<div class="controls">
  <textarea id="textInput" rows="3" cols="50" placeholder="输入要合成的文本"></textarea>
  <br>
  <button onclick="speak()">播放语音</button>
  <button onclick="pauseSpeech()">暂停</button>
  <button onclick="resumeSpeech()">继续</button>
  <button onclick="cancelSpeech()">停止</button>
</div>

<h3>语音历史记录</h3><button onclick="clearAll()">清空</button>
<div class="history-container" id="historyContainer">
  <!-- 历史记录将通过JS动态生成 -->
</div>

<script>
  // 语音合成控制器
  const synthesis = window.speechSynthesis;

  let mediaRecorder;
  let audioChunks = [];
  let audioContext;
  let destinationNode;

  // 初始化 AudioContext(用于录制合成语音)
  function initAudioContext() {
    if (!audioContext) {
      audioContext = new (window.AudioContext || window.webkitAudioContext)();
      destinationNode = audioContext.createMediaStreamDestination();
    }
  }
  function clearAll() {
    localStorage.clear();
    renderHistory()
  }

  // 渲染历史记录
  function renderHistory() {
    const container = document.getElementById('historyContainer');
    container.innerHTML = '';
    const recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
    if (recordings.length === 0) {
      container.innerHTML = '<div class="history-item">暂无历史记录</div>';
      return;
    }

    recordings.forEach((recording, index) => {
      const div = document.createElement('div');
      div.className = 'history-item';
      div.innerHTML = `
          <strong>${escapeHtml(recording.text.substring(0, 30))}${recording.text.length > 30 ? '...' : ''}</strong>
          <div style="font-size: 0.8em; color: #666" onclick="replaySpeech(${recording.id})">
            语速: ${recording.rate}x | 音高: ${recording.pitch} | 语言: ${recording.lang}
          </div>
          <div>
              <button onclick="deleteRecording(${recording.id})">删除</button>
            </div>
        `;

      // 点击历史项回放
      container.appendChild(div);
    });
  }

  // 播放语音
  function speak() {
    const text = document.getElementById('textInput').value.trim();
    if (!text) return alert('请输入要合成的文本');

    initAudioContext();
    audioChunks = []; // 清空之前的录音
    // 创建新的语音对象
    const utterance = new SpeechSynthesisUtterance(text);

    // 获取当前配置(或使用默认值)
    utterance.lang = document.getElementById('langSelect')?.value || 'zh-CN';
    utterance.rate = parseFloat(document.getElementById('rateInput')?.value) || 1.0;
    utterance.pitch = parseFloat(document.getElementById('pitchInput')?.value) || 1.0;
    utterance.volume = parseFloat(document.getElementById('volumeInput')?.value) || 1.0;




    // 2. 创建 MediaRecorder 录制合成后的音频,不在同一个音频通道,无法录制
    // mediaRecorder = new MediaRecorder(destinationNode.stream);
    // mediaRecorder.mimeType = "audio/ogg"; // 或 "audio/webm"
    //
    // mediaRecorder.ondataavailable = (event) => {
    //   if (event.data.size > 0) audioChunks.push(event.data);
    // };
    //
    // mediaRecorder.onstop = () => {
    //   const audioBlob = new Blob(audioChunks, { type: "audio/ogg" });
    //   const audioUrl = URL.createObjectURL(audioBlob);
    //   // 保存到历史记录(限制最多保存20条)
    //   let recording ={
    //     text,
    //     lang: utterance.lang,
    //     rate: utterance.rate,
    //     pitch: utterance.pitch,
    //     volume: utterance.volume,
    //     name: `语音_${new Date().toLocaleString()}.ogg`,
    //     url: audioUrl,
    //     timestamp: new Date().toISOString()
    //   }
    //
    //   // 简单存储在内存中(实际项目建议用 IndexedDB)
    //   let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
    //   recordings.unshift(recording);
    //   localStorage.setItem("speechRecordings", JSON.stringify(recordings));
    //   renderHistory();
    // };

    // 重新渲染历史记录
    renderHistory();

    // 5. 语音结束时停止录制(监听 SpeechSynthesis 事件)
    utterance.onend = () => {
      if (mediaRecorder && mediaRecorder.state !== "inactive") {
        // mediaRecorder.stop();

      }
      let recording ={
        id:'_'+Math.random(),
        text,
        lang: utterance.lang,
        rate: utterance.rate,
        pitch: utterance.pitch,
        volume: utterance.volume,
        // name: `语音_${new Date().toLocaleString()}.ogg`,
        // url: audioUrl,
        timestamp: new Date().toISOString()
      }

      // 简单存储在内存中(实际项目建议用 IndexedDB)
      let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
      recordings.unshift(recording);
      localStorage.setItem("speechRecordings", JSON.stringify(recordings));
      renderHistory();
    };
    // 4. 开始录制 + 播放语音
    // mediaRecorder.start();

    // 播放语音
    synthesis.speak(utterance);
  }

  // 回放历史语音
  function replaySpeech(id) {
    let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
    const item =  recordings.find((item)=>item.id ===id);
    const utterance = new SpeechSynthesisUtterance(item.text);
    utterance.lang = item.lang;
    utterance.rate = item.rate;
    utterance.pitch = item.pitch;
    utterance.volume = item.volume;

    synthesis.speak(utterance);
  }

  // 暂停/继续/停止函数(同前示例)
  function pauseSpeech() { synthesis.pause(); }
  function resumeSpeech() { synthesis.resume(); }
  function cancelSpeech() { synthesis.cancel(); }

  // 防止XSS攻击的简单转义
  function escapeHtml(unsafe) {
    return unsafe
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
  }

  // 播放录音
  function playRecording(url) {
    const audio = new Audio(url);
    audio.play();
  }

  // 删除录音(仅前端,刷新后失效)
  function deleteRecording(id) {
    let recordings = JSON.parse(localStorage.getItem("speechRecordings") || "[]");
    recordings = recordings.filter(r => r.id !== id);
    localStorage.setItem("speechRecordings", JSON.stringify(recordings));
    renderHistory();
  }

  // 初始化页面
  document.addEventListener('DOMContentLoaded', () => {
    // 添加语音配置控件(可选)
    const controls = document.querySelector('.controls');
    controls.innerHTML += `
        <div style="margin-top: 10px">
          <label>语言:
            <select id="langSelect">
              <option value="zh-CN">中文</option>
              <option value="en-US">英文</option>
            </select>
          </label>
          <label>语速: <input type="number" id="rateInput" min="0.1" max="10" step="0.1" value="1"></label>
          <label>音高: <input type="number" id="pitchInput" min="0" max="2" step="0.1" value="1"></label>
          <label>音量: <input type="number" id="volumeInput" min="0" max="1" step="0.1" value="1"></label>
        </div>
      `;

    renderHistory();
  });
</script>
</body>
</html>




三、前端纯离线方案:把模型搬进 WASM

1. 选型

  • espeak-ng:1.2 M 体积,支持 100+ 语料,机械嗓但能用;
  • onnx-tts / piper:基于 VITS,30 M 模型,自然度≈云厂商 70%,CPU 30× 实时;
  • whisper-tts:结合 LLM,情感控制更强,但 100 M+,首次下载劝退。

2. 流水线

文本 → 前端分句 → 标点处理 → WASM 模型 → AudioBuffer →  
      ├─ 直接 play()  
      ├─ MediaRecorder 录成文件  
      └─ Web Audio 加混响、BGM 再输出

3. 代码骨架(espeak-ng 版)

import { EspeakNg } from 'espeak-ng-wasm';

const tts = await EspeakNg.create({ voice: 'zh' });
const buf = await tts.synthesize('你好,这是浏览器里跑出来的离线 TTS');
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start();

体积 1.3 M,首屏 200 ms 内可响,移动端 Snapdragon 660 实测 10 min 文本 2× 实时合成。


四、云端方案:要啥给啥,记得省钱

  • Google Cloud TTS:标准语音 4 美元/百万字符,WaveNet 16 美元;
  • Azure Neural TTS:支持 140+ 语言,SSML 细调情绪;
  • 国内厂商:阿里、讯飞、腾讯按次计费,长文本记得开“超长合成”接口,不然 300 字就断。

前端只需:

const res = await fetch('/api/tts', {
  method: 'POST',
  body: JSON.stringify({ text, voice:'zh-CN-XiaoxiaoNeural' })
});
const blob = await res.blob();
URL.createObjectURL(blob); // 直接播、下载、缓存都行

优点:自然度 90+%,缺点:走网络、有成本、需做配额与降级。


五、混合策略:把云当“高配”,把 WASM 当“保底”

  1. 在线时优先走云,返回 mp3 顺手写 IndexedDB 做 LRU 缓存;
  2. 离线或超出配额自动切到 WASM,保证功能不挂;
  3. 播放层统一用 Web Audio,音量淡入淡出、打断、拼接无裂缝。

六、踩坑小结

  • iOS 必须用户手势触发 AudioContext.resume(),否则静音;
  • MIME 白名单audio/mpegaudio/wavaudio/webm;codecs=opus,其他格式 Safari 直接拒播;
  • 长文本分句再合成,避免单次 AudioBuffer 超过 3 min 导致内存暴涨;
  • GC 陷阱URL.createObjectURL 用完记得 revoke,否则 500 次后卡死。

七、一句话收束

浏览器自带的 speechSynthesis 只能“响个声”,要音质、要离线、要路由、要特效,就把模型搬进 WASM,或者让云端帮你算好。TTS 不再是“播放按钮”,而是一条可缓存、可编辑、可混音的“音频生产线”。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容