让文字跟着声音发光:TTS 字幕时间轴与逐句高亮阅读

14 阅读9分钟

音频在播,文字不动——这是儿童故事阅读页最容易被忽略的体验缺口。

上一篇写完了文本自动转音频播放:故事不但可以被听见,也可以被看见。但"看见"还有一层——如果文字只是静态地躺在那里,小朋友并不知道音频现在读到哪一句。

这篇讲怎么让文字跟着声音一起流动:从 TTS 返回的句级时间戳出发,建立文本与音频的双向映射,最终做成逐句高亮 + 自动跟随的跟读体验。


一、从"播放音频"到"跟读体验"

看下面两个音频播放的动图,上面一个做了文本跟读,下面一个没有——体验的差距一眼就能看出来。 录屏2026-10-22 15.35.19.gif 录屏2026-10-22 15.33.54.gif

作为一个儿童早教类产品,目标之一是希望小朋友在听故事的轻松氛围里加深拼音记忆、掌握更多生字。相比下面,上面的版本能非常直观地看到当前播放进度,让文本与音频真正配合起来。

那么怎么才算把跟读做好了?我认为至少要满足六点:

  1. 当前句高亮——音频播放哪一句,文本中对应的句子就要高亮;
  2. 状态清晰——已读、未读、正在读,需要不同的视觉状态;
  3. 跟随响应及时——进度条拖动之后,文本高亮要立刻跟上;
  4. 视口自动跟随——页面滚动条自动跟随播放进度;
  5. 用户优先——自动滚动的优先级要让位于用户的主动操作;
  6. 拼音汉字同步——拼音与对应汉字的视觉状态要一致。

这六点会在后文逐一落地,第八节给出对照。


二、如何在文本与音频之间建立映射

2.1 TTS 已经把时间给好了

做跟读的核心是让音频与文本段落建立映射关系。好消息是:这个映射不用自己算——调 TTS 时我就发现,长文本语音合成请求成功的返回结果里,恰好带着分段信息。

也就是说,直接取用 TTS 的分段数据,就能把音频与文本段落对应起来。返回示例如下,sentences 即为分段信息,记录了每个段落在音频中的时间坐标区间:

{
  "status": 200,
  "error_code": 20000000,
  "error_message": "SUCCESS",
  "request_id": "c541eae489af48d69dae2d2e203a****",
  "data": {
    "task_id": "35d9f813e00b11e9a2ce9ba0d6a2****",
    "audio_address": "https://example.com/audio.wav",
    "sentences": [
      {
        "text": "今天天气好晴朗。",
        "begin_time": "0",
        "end_time": "2315"
      },
      {
        "text": "欢迎使用语音合成。",
        "begin_time": "2315",
        "end_time": "4355"
      }
    ],
    "notify_custom": ""
  }
}

整体流程可以分成两段:后端把时间戳变成 Cue,前端拿 Cue 驱动渲染。

graph TD
    subgraph backend["后端 · 建立映射"]
        A[&#34;① TTS 返回 sentence 时间<br/>begin_time / end_time&#34;] --> B[&#34;② 对齐回原始正文<br/>换行、空格、重复句&#34;]
        B --> C[&#34;③ 生成并保存 Audio Cue<br/>文本坐标 + 音频坐标&#34;]
    end
    subgraph frontend[&#34;前端 · 驱动渲染&#34;]
        D[&#34;④ 监听 audio 的 timeupdate<br/>currentTime → 毫秒&#34;] --> E[&#34;⑤ 二分查找当前 Cue<br/>O(log n)&#34;]
        E --> F[&#34;⑥ 更新高亮并自动滚动<br/>未读 / 正在读 / 已读&#34;]
    end
    C --> D

2.2 Audio Cue:一套结构,两个坐标系

故事机里的 Cue(字幕)对象长这样:

class StoryAudioCueEntity:
    index: int
    text: str
    start_offset: int
    end_offset: int
    begin_ms: int
    end_ms: int

六个字段,本质上是两套坐标:

字段坐标系含义
index—句子在整篇文本中的序号
text—句子内容
start_offset / end_offset文本坐标句子在原始正文中的起止位置
begin_ms / end_ms音频坐标句子在音频中的播放起止位置

以文本坐标 + 音频坐标结合的方式,才真正建立起文本与音频的映射关系:既可以通过音频播放时间点找到文本,也可以反过来通过文本段落位置找到音频区间。后面所有的交互,本质上都是在这两套坐标之间来回换算。


三、从 TTS 响应中提取句子时间

TTS 返回的 sentences 并不是拿过来就能用。它描述的是"合成系统认为自己读了什么",里面会混进脏数据和业务无关的内容,必须先清洗。

需要过滤掉这几类:

  • 句子文本为空;
  • 开始时间小于 0;
  • 结束时间不大于开始时间;
  • 时间戳倒退(后一句的开始时间早于前一句的开始时间)。
def _parse_sentences(raw_sentences: object) -> tuple[TTSSentence, ...]:
    if not isinstance(raw_sentences, list):
        return ()

    sentences: list[TTSSentence] = []
    previous_begin_ms = -1

    for raw_sentence in raw_sentences:
        if not isinstance(raw_sentence, dict):
            continue

        try:
            text_value = str(raw_sentence["text"]).strip()
            begin_ms = int(raw_sentence["begin_time"])
            end_ms = int(raw_sentence["end_time"])
        except (KeyError, TypeError, ValueError):
            # 字段缺失或类型不对,整条丢弃
            continue

        if (
            not text_value
            or begin_ms < 0
            or end_ms <= begin_ms
            or begin_ms < previous_begin_ms
        ):
            continue

        sentences.append(
            TTSSentence(
                text=text_value,
                begin_ms=begin_ms,
                end_ms=end_ms,
            )
        )
        previous_begin_ms = begin_ms

    return tuple(sentences)

这里的 previous_begin_ms 是一个单调性守卫:只要出现时间倒退的句子就整条丢弃,而不是修正它。因为一旦时间轴出现回退,说明上游数据已经不可信,与其猜,不如让它缺一句。


四、把 TTS 句子重新对齐到原始正文

4.1 为什么需要二次对齐

Cue 需要的是"句子在原文中的位置",而 TTS 返回的是"句子自己认为的文本"。两者之间常有细微差异:

  • TTS 会去掉换行;
  • TTS 会合并连续空格;
  • 同一个句子可能在正文里出现多次,必须标记到正确的那一处。

所以必须再对齐一遍。

4.2 查找策略:先精确,再模糊,最后丢弃

def _find_source_span(
    source_text: str,
    sentence_text: str,
    *,
    start_offset: int,
) -> tuple[int, int] | None:
    # 第一优先:从上次结束的位置开始精确查找
    exact_start = source_text.find(sentence_text, start_offset)
    if exact_start >= 0:
        return exact_start, exact_start + len(sentence_text)

    # 退化方案:忽略所有空白字符后再找
    compact_target = "".join(
        character for character in sentence_text if not character.isspace()
    )
    if not compact_target:
        return None

    indexed_characters = [
        (character, index)
        for index, character in enumerate(source_text[start_offset:], start_offset)
        if not character.isspace()
    ]
    compact_source = "".join(character for character, _ in indexed_characters)
    compact_start = compact_source.find(compact_target)
    if compact_start < 0:
        return None

    # 用索引表把"去空白后的位置"映射回原文的真实偏移
    first_source_offset = indexed_characters[compact_start][1]
    last_source_offset = indexed_characters[
        compact_start + len(compact_target) - 1
    ][1]
    return first_source_offset, last_source_offset + 1

两个设计点值得说:

  • start_offset 传的是上一句的结束位置,所以查找是单调向后推进的。这保证了重复出现的句子能被标记到正确的那一处,也大幅降低了高亮标错位置的风险。
  • 中间那层"忽略空白"的退化查找是关键。TTS 常常会把原文的换行、空格吃掉,导致精确匹配失败;但直接放弃又会丢掉句子。这一层用"字符索引表"的方式做了位置还原,既容错又不会错位。

4.3 组装 Cue

把两套坐标整合成 Cue 对象:

def _build_alignment(
    cls,
    source_text: str,
    sentences: tuple[TTSSentence, ...],
) -> tuple[StoryAudioCueEntity, ...]:
    cues: list[StoryAudioCueEntity] = []
    cursor = 0

    for sentence in sentences:
        span = cls._find_source_span(
            source_text,
            sentence.text,
            start_offset=cursor,
        )
        # 对不上的句子直接跳过,不阻塞其他句子
        if span is None:
            continue

        start_offset, end_offset = span
        cues.append(
            StoryAudioCueEntity(
                index=len(cues),
                # text 取原文切片,而不是 TTS 返回的句子
                text=source_text[start_offset:end_offset],
                start_offset=start_offset,
                end_offset=end_offset,
                begin_ms=sentence.begin_ms,
                end_ms=sentence.end_ms,
            )
        )
        cursor = end_offset

    return tuple(cues)

注意 text 字段取的是 source_text 的切片,而不是 TTS 返回的句子文本——这样能保住原文的标点与空格。这是后面能正确渲染的前提。


五、前端如何找到"正在朗读的句子"

5.1 监听播放进度

数据准备好之后,问题变成:怎么让文本跟音频真实联动?第一步是知道当前播放到哪儿了。原生 <audio> 组件支持监听 timeupdate 事件:

<audio
  ref="audioRef"
  preload="metadata"
  @play="handlePlay"
  @loadedmetadata="applyInitialPosition"
  @pause="handlePause"
  @timeupdate="emitProgress($event.currentTarget)"
  @seeked="handleSeeked"
  :src="props.src"
  :autoplay="props.autoplay"
  :controls="props.controls"
>
  当前浏览器暂不支持音频播放。
</audio>
const emitProgress = (audio = audioRef.value) => {
  if (!audio || !Number.isFinite(audio.currentTime)) return
  emit('progress', Math.max(0, Math.round(audio.currentTime * 1000)))
}

const handleSeeked = (event) => {
  emit('seeked')
  emitProgress(event.currentTarget)
  reportAudioProgress(PLAYBACK_STATES.SEEKED, event.currentTarget)
}

监听 timeupdate 后,把时间单位从秒换算成毫秒抛给故事详情页——统一用毫秒,与后端 Cue 的 begin_ms / end_ms 保持一致,避免在前端到处做单位转换。

那为什么还要单独监听 seeked?timeupdate 的触发频率大约是每秒 4 次,是由播放引擎按固定节奏推的。用户拖动进度条松手之后,音频已经跳到新位置,但高亮还停在上一句,最长要等到下一个 timeupdate 才会追上——两百多毫秒的错位,拖得越频繁越明显。seeked 在跳转完成时触发且只触发一次,用它补一刀,拖动后的高亮就能立刻对齐。

handleSeeked 里做了三件事:

  • emitProgress(event.currentTarget) :用跳转后的最终位置立即重算一次,这是高亮能"立刻跟上"的直接原因。
  • emit('seeked') :把跳转本身作为事件向上抛,父组件可以据此做自己的事(重置跟读状态、打点等),组件不替上层决定。
  • reportAudioProgress(PLAYBACK_STATES.SEEKED, ...) :按 SEEKED 状态上报一次播放进度。带上状态是必要的——同样是"播放到第 30 秒",自然播到和拖过去在看数据时含义完全不同,状态标记让下游能区分这两类行为。(这两个是项目里已有的进度上报封装与状态枚举,与本文主线无关,不影响理解。)

两个事件的分工是:timeupdate 负责连续播放时的跟随,seeked 负责跳转时的纠偏。前者是常态,后者是修正。

5.2 三种状态

详情页根据当前播放时间偏移,与 Cue 的时间区间比较,得出状态:

条件状态
currentTime < beginMs未读
beginMs ≤ currentTime < endMs正在朗读
currentTime ≥ endMs已读

5.3 查找Cue

export function findActiveCueIndex(cues, currentTimeMs) {
  const time = Math.max(0, Number(currentTimeMs) || 0)
  let low = 0
  let high = cues.length - 1

  while (low <= high) {
    const middle = Math.floor((low + high) / 2)
    const cue = cues[middle]

    if (time < cue.beginMs) {
      high = middle - 1
    } else if (time >= cue.endMs) {
      low = middle + 1
    } else {
      return cue.index
    }
  }

  return null
}

timeupdate 的触发频率不低(大约每秒 4 次),如果每次都线性扫一遍 Cue 列表,长文本下会白白消耗主线程。因为 Cue 已经按时间排序(见 6.2 的排序逻辑),这里直接用二分查找,把复杂度压到 O(log n)。


六、把正文切成可高亮的片段

6.1 为什么不能直接用 Cue 渲染正文

三个状态的段落,项目里通过背景色 + 字色两个样式来区分。

截屏2026-10-23 13.46.08.png

这里有个容易踩的坑:不能直接用服务端组装的 Cue 数据去渲染正文。

原始文本中的段落、标点、空格在 Cue 数据里可能已经丢失,个别文本甚至在 Cue 中完全找不到。直接拿 Cue 拼正文,会导致内容缺失、格式错乱,反而破坏阅读体验。

正确做法是:用 Cue 的分段边界,去切割原始正文。

6.2 前端先做一遍数据校验

在切割之前,先对服务端数据做一次防御性归一化:

export function normalizeAudioCues(rawCues, textLength) {
  if (!Array.isArray(rawCues)) return []

  const safeTextLength = Math.max(0, Number(textLength) || 0)

  return rawCues
    .map((cue, fallbackIndex) => ({
      index: Number.isInteger(Number(cue?.index)) ? Number(cue.index) : fallbackIndex,
      text: typeof cue?.text === 'string' ? cue.text : '',
      startOffset: Number(cue?.start_offset),
      endOffset: Number(cue?.end_offset),
      beginMs: Number(cue?.begin_ms),
      endMs: Number(cue?.end_ms),
    }))
    .filter((cue) => (
      Number.isInteger(cue.index)
      && cue.index >= 0
      && Number.isInteger(cue.startOffset)
      && cue.startOffset >= 0
      && cue.startOffset < safeTextLength
      && Number.isInteger(cue.endOffset)
      && cue.endOffset > cue.startOffset
      && cue.endOffset <= safeTextLength
      && Number.isFinite(cue.beginMs)
      && cue.beginMs >= 0
      && Number.isFinite(cue.endMs)
      && cue.endMs > cue.beginMs
    ))
    .sort((left, right) => left.beginMs - right.beginMs || left.index - right.index)
}

这一步做了三件事:字段类型归一、越界数据剔除(startOffset / endOffset 必须落在正文长度内)、按时间排序——最后这个排序,正是 5.3 那个二分查找能成立的前提。

6.3 按 Cue 边界切割 Token

切割的粒度是每个字符,而不是每句话。原因是拼音的 ruby 标签必须以单个汉字为单位渲染(见上一篇),所以切割要在 Token 级别完成,再把属于同一个 Cue 的连续 Token 合并成片段:

const codePointLength = (value) => Array.from(value).length

const paragraphs = computed(() => {
  const source = String(props.text)

  return Array.from(source.matchAll(/[^\r\n]+/g))
    .filter((match) => match[0].trim().length > 0)
    .map((match, paragraphIndex) => {
      const paragraphStart = codePointLength(source.slice(0, match.index))
      let tokenOffset = paragraphStart
      const segments = []

      pinyin(match[0], {
        type: 'all',
        toneType: 'symbol',
        toneSandhi: true,
      }).forEach((item, tokenIndex) => {
        const tokenStart = tokenOffset
        tokenOffset += codePointLength(item.origin)

        // 判断这个字符落在哪个 Cue 里
        const cue = normalizedCues.value.find((candidate) => (
          tokenStart >= candidate.startOffset
          && tokenStart < candidate.endOffset
        ))
        const cueIndex = cue?.index ?? null

        const token = {
          key: `${paragraphIndex}-${tokenIndex}`,
          text: item.origin,
          pinyin: item.pinyin,
          isChinese: item.isZh,
        }

        // 连续且同属一个 Cue 的 Token 合并成片段
        const previousSegment = segments.at(-1)
        if (previousSegment?.cueIndex === cueIndex) {
          previousSegment.tokens.push(token)
        } else {
          segments.push({
            key: `${paragraphIndex}-${segments.length}`,
            cueIndex,
            tokens: [token],
          })
        }
      })

      return { key: paragraphIndex, segments }
    })
})

页面的交互流程是:

graph LR
    A[遍历正文 Token] --> B[计算 Token 在原文中的位置]
    B --> C[判断属于哪个 Cue]
    C --> D[合并连续同 Cue 的 Token]
    D --> E[为整个片段添加状态 class]

到这一步,文本样式就能跟随播放进度同步变化了。但新问题随之而来:音频播到下一屏了,页面还停在原处怎么办?


七、自动跟随阅读,也要尊重用户操作

7.1 安全视口

比较合理的交互是:当前正在朗读的文本,始终待在用户的视口里。这就要求页面监听播放进度并及时更新滚动位置。

项目里设置了一个"安全视口"——距视口顶部 25%、底部 68% 之间的区域。当前播放段落一旦离开这个区域,才触发滚动。这样避免了每换一句就滚一次,也不会让当前句跑到视口之外。

const scrollActiveCueIntoView = async () => {
  if (!props.followPlayback || activeCueIndex.value === null) return

  await nextTick()
  const element = rootRef.value?.querySelector(
    `[data-cue-index="${activeCueIndex.value}"]`,
  )
  if (!element) return

  const bounds = element.getBoundingClientRect()
  const safeTop = window.innerHeight * 0.25
  const safeBottom = window.innerHeight * 0.68

  // 还在安全区内,不动
  if (bounds.top >= safeTop && bounds.bottom <= safeBottom) return

  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches

  element.scrollIntoView({
    behavior: reduceMotion ? 'instant' : 'smooth',
    block: 'center',
    inline: 'nearest',
  })
}

7.2 把控制权还给用户

自动滚动时,如果用户自己滑了一下页面,自动滚动的逻辑不应该跟用户抢夺控制权。所以这里监听了用户的滚动行为,一旦捕获到就立刻停止跟读:

const suspendFollowingForManualScroll = () => {
  if (isAudioPlaying.value && followPlayback.value) {
    followPlayback.value = false // 置为 false 后,跟读停止
  }
}

onMounted(() => {
  window.addEventListener('wheel', suspendFollowingForManualScroll, { passive: true })
  window.addEventListener('touchstart', suspendFollowingForManualScroll, { passive: true })
})

onBeforeUnmount(() => {
  window.removeEventListener('wheel', suspendFollowingForManualScroll)
  window.removeEventListener('touchstart', suspendFollowingForManualScroll)
})

两个实现要点:

  • { passive: true } 。滚动监听里不做 preventDefault,标记为 passive 可以让浏览器不必等回调执行完就滚动,避免滑动卡顿。
  • touchstart 而不是 touchmove。手指一落下就交出控制权,比等真正拖动起来更符合直觉。

不过这里有个值得再确认的边界:touchstart 也会被"点一下播放/暂停按钮"触发。如果你的播放控件在同一个 window 上,用户点一次暂停就会顺带关掉跟读。故事机目前接受这个行为,但如果要做精细,应该判断事件目标是否落在控制区内。


八、总结

回到开头那六点要求,逐条对照:

#要求实现方式对应章节
1当前句高亮Cue 文本坐标 + 二分查找定位当前句二、5.3
2三种视觉状态currentTime 与 beginMs / endMs 比较,挂状态 class5.2、6.3
3拖动进度条即时响应timeupdate 连续跟随 + seeked 跳转后立即重算5.1
4视口自动跟随安全视口判定 + scrollIntoView7.1
5用户操作优先wheel / touchstart 监听,置 followPlayback = false7.2
6拼音与汉字状态对齐Token 级渲染,ruby 与字符同属一个 segment6.3

整套交互的核心只有一句话:让文本与音频的两个坐标系能准确映射起来。文本坐标负责"画在哪",音频坐标负责"什么时候画",Audio Cue 就是这两套坐标的唯一接口。

回顾下来,几个真正花时间的地方都不是"写代码",而是判断:

  • 脏数据该丢还是该修。时间戳倒退的句子直接丢弃,比猜测着修更安全——错的高亮比没有高亮更糟糕。
  • 能不能直接信后端。Cue 数据不能直接渲染正文,前端必须保留一份原始文本,只借用 Cue 的边界。
  • 自动化的边界在哪。自动滚动看起来很贴心,但一旦和用户的操作冲突,就必须让位。体验上的"自动",前提是不打断用户。