颜力刚Ligang Yan

如何设计一个像 Speechify 那样的 PDF 朗读服务

从需求和容量估算开始,定义核心实体和接口,画出高层架构,再逐个深入:大 PDF 怎么分句、点哪读哪怎么实现、上传高峰怎么扛、高并发下怎么避免重复生成语音。这是我做 PDF 朗读产品时的系统设计过程。

architecturepdfspeechifysystem-designtext-to-speech

English version: How to Design a PDF Text-to-Speech Service Like Speechify

文本转语音(TTS)服务已经成为让内容更易获取的常用工具,对更愿意听而不是读的用户很方便。最知名的 TTS 平台之一是 Speechify,它把包括 PDF 在内的数字文本转成高质量音频。我动手做一个类似 Speechify 的 PDF 朗读服务时,遇到了不少挑战,也看到了机会。这篇文章把我做系统设计的过程走一遍:从理解用户需求,到选技术栈,再到怎么扩展。

最终的高层架构图如下:

理解问题

功能需求

在进入架构和设计之前,先明确这个服务要提供什么。我定的核心功能:

  1. 用户可以上传 PDF 文件。
  2. 用户可以看到已上传的 PDF 列表,并预览单个文件。
  3. 用户点一个按钮,系统从头开始朗读。
  4. 用户点击 PDF 里的任意一句文本,系统从这句开始朗读,并自动继续读后面的内容。
  5. 用户可以调整语音和播放速度。

非功能需求

  1. 系统必须能快速处理大 PDF,几百 MB 的文件也要在几秒内处理完。
  2. 开始朗读的延迟要极低,100 毫秒以内,连续朗读时没有明显停顿。
  3. 支持 100 万日活用户。
  4. 高可靠,99.99% 可用性,可用性优先于一致性。

容量估算

容量估算帮我们提前评估系统规模,在给定假设下需要多少计算和存储资源,也帮助设计扩展性、考虑成本、评估性能和可靠性。

假设

  • 100 万日活用户。
  • 每个用户每天上传一个 1MB 的文档。
  • 每个用户每天请求 5 次 PDF 列表和 PDF 详情。
  • 每个用户每天听 1 小时 TTS。

估算

  • 文档列表请求:
    • 每个用户每天请求 5 次。
    • 100 万用户/天 × 5 次/用户 = 500 万次/天
    • 500 万次/天 ÷ 100,000 秒/天 ≈ 50 次/秒
  • 文档详情请求:
    • 同样每天 5 次。
    • 500 万次/天 ÷ 100,000 秒 ≈ 50 次/秒
  • 文档上传:
    • 每个用户每天上传 1 个文档(1MB)。
    • 100 万次/天 ÷ 100,000 秒 ≈ 10 次/秒
  • TTS 音频生成:
    • 1 小时音频约 50,000 个字符(假设一句 100 个字符)。
    • 50,000 字符 ÷ 100 = 每个文档 500 句
    • 每个用户每天 500 次 TTS 请求
    • 500 次/用户 × 100 万用户/天 = 5 亿次/天
    • 5 亿次/天 ÷ 100,000 秒 = 5,000 次 TTS 请求/秒

存储需求

  • PDF 存储:
    • 100 万用户/天 × 1MB × 365 天/年
    • 合计 3.65 亿 MB = 每年 365 TB
  • 音频存储:
    • 假设每句 TTS 音频 0.1MB:
    • 100 万用户/天 × 500 句 × 0.1MB × 365 天/年
    • 合计 每年 18,250 TB(每天 5,000 万 MB × 365)

搭建

定义核心实体

定义处理 PDF、生成 TTS 输出、管理可用语音所涉及的核心实体。

PDF

  • id:PDF 的唯一标识。
  • fileName:文件名。
  • filePath:存储位置(云存储路径或本地路径)。

TTS

  • id:TTS 的唯一标识。
  • text:要转成语音的文本。
  • {platform}:{model}:{voice}:平台、模型、语音组合而成的唯一字符串标识(比如 AWS:Polly:Neural、Google:Standard:EnglishA)。

Voice

  • id:语音的唯一标识。
  • platform:TTS 平台(比如 AWS Polly、Google Cloud Text-to-Speech)。
  • model:语音模型(Neural、Standard 等)。
  • voice:具体的语音选项(比如 “Joanna”)。
  • languageCode:语言代码(比如 “en-US”)。
  • voiceName:人类可读的语音名称。

API 或系统接口

定义以下 REST 接口,处理 PDF 上传、获取可用语音、提交 TTS 请求。

PDF 管理 API

  • 上传 PDF
    • 接口:POST /docs
    • 请求体:
    {
      "fileName": "example.pdf",
      "filePath": "/uploads/example.pdf"
    }
    • 响应:成功或错误信息,附上传 PDF 的 id。
  • 列出所有 PDF
    • 接口:GET /docs
    • 响应:
    [
      {
        "id": "1",
        "fileName": "example.pdf",
        "filePath": "/uploads/example.pdf"
      },
    ]
  • 按 ID 获取 PDF
    • 接口:GET /docs/:id
    • 响应:
    {
      "id": "1",
      "fileName": "example.pdf",
      "filePath": "/uploads/example.pdf"
    }

TTS API

  • 列出可用语音
    • 接口:GET /tts/voices
    • 响应:
    [
      {
        "id": "1",
        "platform": "AWS Polly",
        "model": "Neural",
        "voice": "Joanna",
        "languageCode": "en-US",
        "voiceName": "Joanna"
      },
    ]
  • 提交 TTS 请求
    • 接口:POST /tts
    • 请求体:
    {
      "text": "Hello, welcome to our platform!",
      "platform": "AWS Polly",
      "model": "Neural",
      "voice": "Joanna",
      "languageCode": "en-US"
    }
    • 响应:成功信息附生成的 TTS 文件 id,或错误信息。

高层设计

从一个简单场景开始。假设一个 PDF 只有一页,每一行是一句完整的话,比如:

Hello Everyone. I'm Allen. Nice to meet you.

1. 基本流程

  • 第 1 步:把 PDF 上传到存储(比如 AWS S3)。
  • 第 2 步:存储返回文件路径。
  • 第 3 步:调用后端的 upload() 接口,把 PDF 信息(fileName、filePath)存进数据库。
  • 第 4 步:客户端调用 getPdfs() 获取 PDF 列表。
  • 第 5 步:客户端调用 getPdfById() 获取某个 PDF 的详情,包括文件路径。
  • 第 6 步:客户端用 react-pdf 库渲染 PDF,传入文件路径。
  • 第 7 步:客户端用 pdfjs-dist 解析 PDF 内容,提取所有句子。
  • 第 8 步:对每一句调用 textToSpeech() 接口,拿到 TTS 音频地址。
  • 第 9 步:直接用这个地址播放音频。

2. TTS 缓存机制

为了优化系统,可以缓存 TTS 结果。调用 textToSpeech() 时,用句子的 MD5 哈希生成一个唯一 ID,相同的输入就能复用同一个音频文件,不用重新生成。不同的平台、模型或语音会生成不同的文件路径,但归在同一个 ID 下。

例如:

{
   "id": "jAR8eju7eZS9LN5RP9TP77jG3lnnEsNQaZ_QAspBo",
   "text": "Hello Everyone. I'm Allen. Nice to meet you",
   "google-standard-EnglishA": { "filePath": "/tts/google/EnglishA.mp3" },
   "openai-tts-1-alloy": { "filePath": "/tts/openai/Alloy.mp3" }
}
  • 键 "jAR8eju7eZS9LN5RP9TP77jG3lnnEsNQaZ_QAspBo" 是文本的 MD5 哈希。
  • 平台、模型、语音的组合(google-standard-EnglishA、openai-tts-1-alloy)对应不同的音频文件路径。

这样,用户用相同的 TTS 参数请求同一句话时,系统直接从存储取缓存结果,不用重新生成,减少重复的 TTS 生成,节省时间和算力。

3. PDF 服务和 TTS 服务分离

出于扩展性考虑,把 PDF 服务和 TTS 服务分开:

  • 从容量估算可以看到,TTS 请求的并发量大约是 PDF 相关操作的 500 倍。
  • TTS 服务需要的水平扩展远多于 PDF 服务。
  • 两个服务各自独立运行,各有各的扩展需求。

4. 数据库选型

考虑到:

  • 表之间没有复杂关系(不需要外键和 join)。
  • TTS 服务因为高并发会产生大量写操作。

MongoDB 或 DynamoDB 这类 NoSQL 数据库更适合存 PDF 和 TTS 数据。NoSQL 针对高写入吞吐优化,也容易水平扩展。

这个高层设计给出了一个简单且可扩展的架构:把 PDF 服务和 TTS 服务分开以应对不同的并发量;用 MD5 哈希做键缓存 TTS 结果,避免重复生成;用 NoSQL 保证高写入吞吐。这个架构可以很容易地扩展到更多场景和更高负载。


可能的深入讨论

1. 大 PDF 文件怎么处理

第一版设计在处理大 PDF 时有个明显问题:如果客户端一次性请求整个大 PDF 的 TTS,延迟会非常高。解决办法是把文本切成小块。有两种可能的方案:

方案一:客户端提取文本并分句

  • 流程:
    • 客户端从 PDF 提取全部文本,用正则按标点(句号、问号、感叹号)切成句子。
    • 每一句作为单独的请求发给服务端的 TTS 接口转成音频。
  • 优点:
    • 处理简单:逻辑相对简单,服务端不用改。
    • 请求更小:TTS 请求更小更快,服务端每次处理时间更短。
  • 缺点:
    • 正则不准:正则分句不一定准确,比如 “Mr.”、编号 “1.” 之类的特殊情况会切错。
    • 每次重新处理:每次打开文档客户端都要重新解析和分句,可能造成延迟或卡顿。

方案二:上传时在服务端分句

  • 流程:
    • 用户上传 PDF 时,服务端立刻处理 PDF 并提取全部文本。
    • 服务端用一个分句服务把文本切成正确的句子。
    • 分好的句子存成一个 .json 文件,放在云存储里。
    • 客户端请求某个 PDF 的 TTS 时,直接取这个预处理好的 .json,每一句单独处理。

什么是分句服务

  • 分句服务是一个用自然语言处理(NLP)技术把任意文本切成句子的工具,比简单正则准确得多。它能处理复杂句式、缩写和标点规则,给出更可靠的分句结果。
  • 例子:对于 “Dr. Smith is here. She arrived at 10:00 a.m. Can you see her?”,一个合格的 NLP 分句工具会正确理解 “Dr.” 和 “a.m.”,不会切错。

可用的 NLP 分句库:

  • spaCy:流行的 NLP 库,提供分句能力。
  • NLTK:另一个强大的文本处理和分句库。
  • Stanford CoreNLP:一套 NLP 工具,包含分句功能。

服务端分句的优点

  • 处理高效:PDF 只在服务端处理一次,之后客户端直接从 .json 取分好的句子,减轻客户端负担,加快 TTS。
  • 分句准确:用 NLP 分句服务,错误比正则少得多。
  • 性能更好:分句只在上传时做一次,后续请求只是取预处理好的数据,更快。

服务端分句的缺点

  • 复杂度增加:需要在上传流程里多一个 NLP 分句服务。
  • 存储增加:分好句的文本要和原 PDF 一起存,存储略增。

对于处理大量 PDF 的系统,方案二(服务端分句)更高效、更可扩展。它避免了客户端的重复处理,保证分句准确,TTS 生成更快更顺。虽然前期要多搭一个分句服务、多存一份 .json,但性能和准确性上的长期收益让它成为更好的选择,尤其是高流量系统。

更新后的高层架构图:


2. 怎么实现“点哪读哪”

读 PDF 的时候,用户往往不想从头开始听,而是想点击文档里的任意一句,从那里开始。实现这个功能,需要把 PDF 里的文本准确映射到屏幕上的位置。一步一步来:

第 1 步:用 pdfjs-dist 提取文本和位置

先解析 PDF,提取文本内容和位置数据。pdfjs-dist 提供这样的结构:

TextItem 数据结构

{
  text: 'Hello',
  transform: [300, 0, 0, 10, 300, 500], // 位置 (x: 300px, y: 500px)
  width: 100, // 文本框宽度(像素)
  height: 10  // 文本框高度(像素)
},
{
  text: 'World',
  transform: [300, 0, 0, 10, 0, 300],
  width: 100,
  height: 10
}

这个例子里,“Hello” 位于 (x: 300px, y: 500px),宽 100px,高 10px。提取出来的文本和位置数据,是确定每个词或每句话在页面上位置的基础。

第 2 步:生成句子和位置的映射

要实现点哪读哪,需要把每一句映射到 PDF 页面上的准确位置。把句子文本(来自 sentences.json)和从 PDF 提取的位置信息(来自 textItems.json)结合起来:

  • sentences.json:PDF 里的每一句,已经切成 TTS 用的小块。
  • textItems.json:用 pdfjs-dist 提取的每个词或短语的精确位置。

第 3 步:生成 highlights.json

用 sentences.json 和 textItems.json 生成 highlights.json,把每一句映射到它在 PDF 上对应的矩形坐标(x、y、宽、高)。

highlights.json 结构

[
  {
    "sentence": "Hello Everyone.",
    "rects": [
      {
        "x": 300,
        "y": 500,
        "width": 100,
        "height": 10
      },{
        "x": 0,
        "y": 300,
        "width": 100,
        "height": 10
      }
    ]
  },
  {
    "sentence": "I'm Allen.",
    "rects": [
      {
        "x": 320,
        "y": 520,
        "width": 120,
        "height": 10
      }
    ]
  }
]

在这个结构里,每一句都有一组坐标,定义它出现在页面的哪里。这些坐标可以用来在文档上创建可点击区域。

第 4 步:叠加高亮和可点击区域

有了 highlights.json,下一步是在 PDF 上盖一层透明图层,用来捕获点击和显示高亮:

  • 高亮层:用 HTML/CSS 在 PDF 查看器上创建一个透明层。对每一句,按 highlights.json 里的坐标创建一个不可见的可点击框。
  • 悬停和点击事件:用户悬停或点击某一句时,高亮区域变为可见,并触发对应的 TTS 播放。

高亮框的 CSS

.highlight-box {
  position: absolute;
  background-color: rgba(255, 255, 0, 0.3); /* 黄色高亮 */
  border: 1px solid rgba(255, 255, 0, 0.7);
  cursor: pointer;
}

处理点击的 JavaScript

document.querySelectorAll('.highlight-box').forEach(box => {
  box.addEventListener('click', function() {
    const sentence = this.getAttribute('data-sentence');
    playTTS(sentence); // 用被点击的句子调用 TTS
  });
});

这样用户就可以点击 PDF 里任何高亮的文本,从那一句开始朗读。

这个方案的优点

  1. 精确选句:用户可以点任意一句从那里开始听,不必从头听。
  2. 位置准确:用 pdfjs-dist 提取的文本数据,保证可点击区域和 PDF 上的文本位置完全对应。

实现 PDF 的点哪读哪,需要把每一句映射到文档上的精确位置。用 pdfjs-dist 提取文本和位置数据,生成存有每句坐标的 highlights.json,用户就可以点击文档任意位置触发 TTS 播放。再配合高效的 TTS 缓存,用户体验流畅、响应快。


3. 上传高峰怎么处理

容量规划时我们估算的平均流量是每秒 1 万次 PDF 上传,高峰可能到每秒 3 万。处理一个 PDF 要几秒钟,怎么设计才能既保证低延迟又保证高吞吐?两种可能的方案:

方案一:用异步队列解耦 PDF 处理

把 PDF 服务和 PDF 处理服务解耦。处理服务可以水平扩展,流量高峰时增加实例来应对。

架构如下:

  • PDF 服务:用户上传 PDF 后立即存储,并向 Kafka(或其他消息中间件)队列发一条消息,带上文档元数据和文件路径。
  • PDF 处理服务:订阅 Kafka 队列,异步处理上传的 PDF:提取文本,生成高亮,为后续操作做准备。
  • 进度跟踪:客户端用轮询或 WebSocket 查询文档处理状态。系统实时更新进度,文档状态从 pending 到 processing,全部完成后变为 completed。

优点:

  • 水平扩展:处理服务可以独立扩展,高效应对流量突增。
  • 异步处理:PDF 处理不阻塞上传流程,用户得到更快的响应。

缺点:

  • 实时监控:跟踪文档状态、管理用户对处理时间的预期,增加了一些复杂度。

方案二:分块处理

把 PDF 拆成小块独立处理。比如每 10 页一块。每处理完一块就更新文档元数据,文档状态从 pending 变为 processing。

  • 分块:PDF 上传后切成若干块,每块包含一定页数(比如 10 页)。
  • 并行处理:每块独立并行处理。一块处理完,对应的 doc.state 就从 pending 更新为 processing,表示部分可用。
  • 客户端渲染:客户端不需要一次渲染整个文档。它按当前页码和块索引请求高亮数据。比如用户在看第 21 页,系统知道要渲染 chunkIndex = 2,只取那一块的高亮。

优点:

  • 反馈更快:用户不用等整个文档处理完就能开始交互,几乎立刻就能看和用文档的一部分。
  • 资源利用高效:大 PDF 拆成小块并行处理,整体吞吐更高,处理时间更短。

缺点:

  • 复杂度增加:分块以及管理文档和高亮的部分状态,让后端处理和客户端逻辑都更复杂。


4. 高并发下怎么避免重复的 TTS 请求

高并发系统里,必须保证相同的 TTS 请求不会为同一段输入生成多个音频文件。这不仅浪费资源,还可能造成性能瓶颈。可以用 Redis 分布式锁来避免重复生成。

方案:Redis 分布式锁

在向外部服务(OpenAI、Google Cloud TTS 等)发起 TTS 请求之前,先用 Redis 分布式锁保证同一段文本只被处理一次。


结论

这篇文章讨论了怎么设计一个类似 Speechify 的 PDF 朗读系统。先定义功能范围,找出主要实体和接口,画出简单的高层架构图;再逐步满足功能和非功能需求,比如高扩展性和低延迟,把设计一层层改进。

为了把这套设计付诸实践,我从零做了 DocWiser,一个 Speechify 的替代品,支持导入 PDF、TXT、EPUB 等格式做朗读。它和 Speechify 一样提供免费试用,高级功能需要订阅。这个产品现在已经下线,但设计过程里的取舍仍然适用。

English version.