资讯动态

Pipecat TTS 逐词跟踪架构:让 LLM 文本、屏幕渲染与语音播报逐词同步的三层设计

发布时间:2026/9/14 15:43:20 来源:尧图企业网站定制
Pipecat TTS 逐词跟踪架构让 LLM 文本、屏幕渲染与语音播报逐词同步的三层设计【免费下载链接】pipecatOpen Source framework for voice agents, multimodal apps, and realtime AI. Maintained by Daily and the community.项目地址: https://gitcode.com/GitHub_Trending/pi/pipecat本篇技术指南基于 Pipecat 仓库中的架构文档 TTS Word Tracking 展开深入讲解 Pipecat 如何在 TTS 播放音频的同时让LLM 写的文本、下游消费者渲染的文本和TTS 实际朗读的文本三种文本逐词保持同步。读完本文你将理解这套机制要解决的上下文漂移、帧乱序、词级高亮缺失等核心问题掌握TextSegmentMap、WordCompletionTracker、AggregatedFrameSequencer三层的职责划分与接线方式并能复用code-helper示例的四步配置模式实现词级高亮、敏感信息脱敏等实战功能。1. 最终目标一次 LLM 输出要满足三种不同的文本需求Pipecat 的逐词跟踪要回答一个基本问题LLM 的一次响应必须同时满足三个想要不同文本的消费者。以一个提示 LLM 将信用卡号包裹在card标签、代码包裹在code标签中的机器人为例即code-helper示例LLM 产生的输出Your card is card1234-5678-9012-3456/card. Run codenpm install/code to start.从这单一输出出发必须发生三件事消费者想要的文本原因对话上下文Your card is card1234-5678-9012-3456/card. Run codenpm install/code to start.LLM 必须在下一轮看到自己的标签否则会停止生成标签用户屏幕贯穿本文的示例——任何下游消费者都以相同方式工作Your card is XXXX-XXXX-XXXX-3456. 语法高亮的代码块每个词被朗读时逐个加粗UI 负责渲染与脱敏原始标签是噪声TTS 提供商Your card is spell1234-5678-9012-3456/spell.——代码块什么都不读数字必须逐位朗读代码不应被朗读TTS 提供商会流式返回词时间戳word-timestamp事件其中只包含它实际朗读的词。这些词是唯一可用的信号。整套架构要回答的就是对于每个到达的词——我们在哪里——在显示文本中的哪个位置以便 UI 高亮应该往上下文里追加什么——LLM 原文的哪个片段以便上下文保留标签这个帧是否已就绪可以下发——相对于本轮所有其他帧以什么顺序下发。2. 原始问题没有这套机制时会发生什么2.1 对话上下文偏离了 LLM 实际写的内容追加到对话上下文的文本是从 TTS 返回的文本而不是 LLM 产生的文本。所有为了让输出可朗读而做的处理——合成前移除的标签、为发音重写的值——在往返中全部丢失最终落入上下文的从来不是 LLM 写的那份文本。这种失败模式是微妙而缓慢的LLM 被要求用card标签包裹信用卡号第一轮它照做了但随后它在上下文中看不到自己写的标签。几轮之后它会得出标签不是对话的一部分的结论停止生成标签——功能在无声中退化。之前现在上下文收到Your card is 1234 5678 9012 3456Your card is card1234-5678-9012-3456/card下一轮LLM 看不到标签停止生成LLM 看到自己的约定继续保持2.2 被跳过的帧乱序到达一个从不发送到 TTS 的帧——比如配置了skip_aggregator_types[code]的代码块——既没有音频也没有任何词事件可供等待。旧实现中它一出现就被推送于是落在了它前面那句话的之前LLM: Run this: → codenpm install/code → Then reload. Context: codenpm install/code ← 先到达没有可等待的东西 Run this: ← 更晚才被朗读 Then reload.转录记录因此乱序而在被打断时问题更糟从未被朗读的文本可能被记录得像已经朗读过一样。2.3 无法高亮正在朗读的词UI 收到句级帧AggregationType.SENTENCE用于渲染随语音推进还收到词级帧AggregationType.WORD。但两者之间没有对应关系——一个词帧不携带它属于哪一句句帧、位于句内什么位置的信息。客户端无法把一串词变成渲染句子中移动的高亮光标。2.4 RTVI 的bot_output_transforms逐词工作时毫无用处客户端侧的文本变换——例如在信用卡号到达屏幕前将其脱敏——作用对象是一个段segment。如果逐词接收文本既没有段身份也没有已朗读与剩余的概念就没有任何可供一致变换的对象。一个以1234、5678、9012、3456四个割裂事件到达的信用卡号根本无法脱敏。2.5 沿途发现的问题问题会发生什么流式 token在TOKEN模式下服务下发的是词大小的块但跟踪与进度需要完整句子——而句子在边界确认前未知并发上下文websocket 服务上两个连续TTSSpeakFrame可能同时在飞它们的词流会交错且不得互相消费对方的槽位跨段 token提供商的一个 token 可能完成当前帧并开始下一帧如1111And因此单个事件必须按正确顺序为两个槽位产生输出丢失事件提供商静默地从不报告某个它读过的词导致一个帧——以及其后排队的所有帧——永远等待下去2.6 以及一个功能需求文本变换Text TransformsTTS 引擎读什么取决于你给它什么而书面文本常常不是你希望被说出口的内容$42.50可能读成 dollar forty two point five zero1994在句子含义是 nineteen ninety four 时却读成 one thousand nine hundred ninety fourmarkdown 星号会被读出来URL 变成 h t t p s colon slash slash。修复办法是在文本到达 TTS 之前重写它让音频输出正确。障碍在于被重写的文本同时是其他所有组件看到的文本。为合成器展开$42.50意味着用户在屏幕上读到 forty-two dollars and fifty cents对话上下文也以这种形式记录——一个改善音频的变换会同时污染转录。把三份文本分开跟踪正是让重写变得安全的关键它只到达 TTS不会到达任何其他地方。内置的变换位于 src/pipecat/utils/text/transforms/由VoiceFormattervoice_formatter.py打包提供变换输入发送给 TTSexpand_currencyIt costs $42.50It costs forty-two dollars and fifty centsexpand_percentagesUp 12.5%Up twelve point five percentexpand_unitsIt is 5 km awayIt is 5 kilometers awaynormalize_dateson 2024-01-15on January 15th, two thousand and twenty-fouremail_to_speechab.coma at b dot comexpand_phone_numberscall 555-123-4567call 5 5 5 1 2 3 4 5 6 7expand_numbersroom 1994room 1 9 9 4normalize_acronymsI work at IBMI work at I B Mstrip_markdown**bold** textbold text此外还可以在 TTS 服务上按段注册逐段变换器tts.add_text_transformer(fn, credit_card)对应 tts_service.py 的add_text_transformer方法code-helper示例正是用它给卡号包裹spell标签并剥离链接中的https://。每一个变换都以拉大被朗读文本与被显示文本之间差距为代价换来更好的音频——而这正是三层结构必须弥合的差距。它们在SPLIT 2阶段被应用见下节。3. 解决方案总览解法分为两半。首先三份文本在产生时就被保持为彼此独立从而无需事后重建3.1–3.3其次在 TTS 朗读期间三个层次让它们在逐词粒度上保持对齐3.4–3.5。问题由谁解决2.1 上下文漂移TextSegmentMap的llm_pos游标 WordCompletionTracker的 span 归属2.2 跳过帧乱序AggregatedFrameSequencer的槽位队列2.3 词↔句对应关系缺失AggregatedTextProgressFrame由 tracker 的游标构建2.4 RTVI 变换逐词无用每个进度帧都携带segment_idaccumulated_text/remaining_text2.5 流式/并发/TTS 提供商怪癖Sequencer流式、上下文 Tracker跨段、丢词2.6 文本变换TextSegmentMap对变换后段的处理3.1 三份文本产生于两个切分点三份文本并非被分别撰写而是由两个切分点产生一个归 aggregator 所有一个归 TTS 服务的变换器所有LLM tokens │ ▼ ┌───────────────────────────────────────────────┐ │ LLMTextProcessor (或 TTSService 本身) │ SPLIT 1 —— aggregator │ BaseTextAggregator, 例如 │ 决定段在哪里结束、 │ PatternPairAggregator / SimpleTextAggregator │ 什么算作分隔符 └───────────────────────────────────────────────┘ │ ├──────── raw_text ─────────▶ ① LLM TEXT → 对话上下文 │ └──────── text ─────────────▶ ② SEGMENT TEXT → 下游消费者 │ (即 AggregatedTextFrame) ▼ ┌──────────────────────┐ │ TTSService │ SPLIT 2 —— filters 与 │ 文本过滤器 │ 逐类型变换器 │ 文本变换器 │ 仅为语音重写 └──────────────────────┘ │ ▼ ③ TTS TEXT → 提供商text与raw_text都来自同一次聚合当 aggregator 产生了PatternMatch时raw_text就是它的full_match否则与text完全相同。SPLIT 2 就是上面 §2.6 列出的那些变换运行的位置。这一结构带来三条性质中间通道就是AggregatedTextFrame本身。架构文档称它为segment text段文本因为那正是它——frame.text即 aggregator 产出的那个段。它永不被重写。过滤器和变换器作用于送往 TTS 的一份副本frame.text始终保持 aggregator 产出的原样。三份文本只在有东西动了时才分歧。使用SimpleTextAggregator且没有变换器时三份是同一个字符串。3.2 对一次响应而言长什么样PatternPairAggregator把那次响应切分为六个帧——每个带标签的 span 成为独立段、拥有自己的类型#aggregated_by① LLM 文本raw_text② 段文本text③ TTS 文本1sentenceYour card isYour card isYour card is2credit_cardcard1234-5678-9012-3456/card1234-5678-9012-3456spell1234-5678-9012-3456/spell3sentence...4sentenceRunRunRun5codecodenpm install/codenpm install从不发送——被跳过6sentenceto start.to start.to start.只有两个带标签的帧做了有意思的事。帧 2 的分隔符移入了raw_text使上下文保留它们它的credit_card变换器把数字包进 Cartesia 的spell标签使数字逐位读出。帧 5 根本不会被朗读——这正是 §2.2 中乱序问题的来源。四个sentence帧在三列中完全相同。aggregated_by是贯穿始终的路由键它决定哪个变换器被应用tts.add_text_transformer(fn, credit_card)、帧是否被朗读skip_aggregator_types[code]、哪个 RTVI 变换来脱敏它bot_output_transforms[(credit_card, …)]。3.3 让它真正有用的那条保证无论 aggregator 在frame.text中放了什么当该段正在被朗读时恒有progress.accumulated_text progress.remaining_text AggregatedTextFrame.text精确相等——逐字符在每一个词之后都成立。进度帧的两个半部分永远是段帧已携带字符串的一次干净切分从不是它的意译、从不是归一化后的副本、从不会差一个空格。这正是进度帧可被任何组件无协调使用的原因。持有段帧的消费者不需要猜测文本在送往 TTS 途中如何被变换也不需要自行重推位置——它可以直接索引到已经持有的字符串里。高亮前半部分、保留后半部分为普通样式就有了词级高亮对两半都脱敏就有了脱敏功能拼接它们就得到原文。架构文档以UI 高亮正在朗读的词作为贯穿示例因为它最直观——但仅此而已只是一个例子。自定义 processor、转录写入器、脱敏过滤器或日志组件都以完全相同的条款消费同样的帧。API 把这个通道命名为user_facing_*user_facing_text、user_facing_pos、get_accumulated_user_facing_text()。可以从 aggregated_frame_sequencer.py 的user_facing_text字段定义和 word_completion_tracker.py 的get_accumulated_user_facing_text()方法中确认这些 API 命名。概念层面本文称段文本指代实际 API 成员时保留user_facing_*命名相关命名讨论见 improvements。3.4 三个层次TTS provider ── 词时间戳事件 ──┐ ▼ ┌────────────────────────────────────────────────────────────┐ │ AggregatedFrameSequencer 每个 TTS 服务一个 │ │ │ │ 有序槽位队列: [ 已朗读 ][ 已跳过 ][ 已朗读 ] … │ │ │ │ │ │ ┌──────▼────────┐ ┌────▼─────────┐ │ │ │ WordCompletion│ │ WordCompletion│ │ │ │ Tracker │ │ Tracker │ │ │ │ ┌───────────┐ │ │ ┌──────────┐ │ │ │ │ │ TextSeg │ │ │ │ TextSeg │ │ │ │ │ │ mentMap │ │ │ │ mentMap │ │ │ │ │ └───────────┘ │ │ └──────────┘ │ │ │ └────────────────┘ └────────────────┘ │ └────────────────────────────────────────────────────────────────┘ │ │ ▼ ▼ TTSTextFrame AggregatedTextProgressFrame → 对话上下文 → RTVI → 客户端每一层各负其责且不知道上层层的存在层次作用域回答的问题为什么必须存在TextSegmentMap一个帧逐段给定这个被朗读的词我们在另外两份文本的什么位置送往 TTS 的文本与 LLM 所写或段所携带的文本不同必须有东西在变换与标记之间持三份对齐的游标同时匹配噪声化、提供商特定的 tokenWordCompletionTracker一个帧这个词有多少属于本帧、它代表哪份原文、该帧是否完成即使 TTS 提供商行为异常一个帧也需要逐词的完成判定与被归属的 LLM spanAggregatedFrameSequencer整个回合帧以什么顺序离开 TTS 服务词是逐帧到达的但对话上下文是全局有序的——已朗读、已跳过、缓冲中、并发上下文的帧必须被串行化到一条时间线上三层的核心类在源码中的位置可以直接核对TextSegmentMap定义在 text_segment_map.py#L147其类文档写明职责是回答我们在哪里——在同一句话的三份版本中逐词回答WordCompletionTracker定义在 word_completion_tracker.py#L16暴露get_llm_consumed()L330返回归属到当前词的 LLM spanget_accumulated_user_facing_text()/get_remaining_user_facing_text()L345–L353提供已读/剩余切分AggregatedFrameSequencer定义在 aggregated_frame_sequencer.py#L223。从 text_segment_map.py 的类文档可以看到TextSegmentMap的底层原理构建时先把tts_text与original_text做 diff切成对齐的TextSegment片段未改动或整体被重写之后只移动一个真实游标raw_pos提供商在读到tts_text的哪里user_facing_pos与两个 LLM 游标跟随它——在未改动段中逐词跟平在被重写的段中等待$42.50与 forty two dollars 朗读过程中不存在诚实的中间位置最后一个词落定时整段跳到 span 末尾。llm_text从不参与 diff 比较因为它与original_text拥有相同字母数字序列、只是包裹物不同按字母数字计数即可保持同步。3.5 接线位置全部由TTSService驱动三层全部由 src/pipecat/services/tts_service.py 中的TTSService驱动。服务拥有一个AggregatedFrameSequencersequencer 为每个槽位构建一个WordCompletionTracker每个 tracker 构建一个TextSegmentMap。TTSServiceSequencer 方法时机_push_tts_framesregister_spoken一个帧被派发到 TTStts_service.py#L1294_push_tts_framesregister_skipped一个帧绕过 TTS例如代码块tts_service.py#L1143_add_word_timestampsprocess_word一个词时间戳事件到达tts_service.py#L1486_apply_force_completeforce_complete一个音频上下文结束tts_service.py#L1695-L1706文本输入结束finalize该上下文不再有 tokentts_service.py#L810打断interruptionclear本轮被取消tts_service.py#L1044还有两个排序问题留在服务层而非 sequencer 层因为两者都涉及 sequencer 看不到的队列TTSService做什么为什么push_frame用max(_word_last_pts, clock.now())给will_be_spoken锚点打 PTS 戳tts_service.py#L1227 附近进度帧携带 PTS走传输层的时钟队列_push_sequencer_frames流式场景下把 sequencer 发出的所有帧路由到音频上下文队列tts_service.py#L1113保持单一消费者按顺序发出锚点、词与音频而不是与音频上下文任务竞速AggregatedFrameSequencer每次process_word调用会构建至多两个帧分别指向不同消费者TTSTextFrame携带该词及其raw_textLLM span送往对话上下文AggregatedTextProgressFrame携带segment_idaccumulated_text/remaining_text送往任意下游消费者通常是经 RTVI 到 UI。进度帧的定义可直接在 frames.py#L424-L446 中核对其字段为segment_id、context_id、text、aggregated_by、accumulated_text、remaining_textdocstring 明确说明它使下游消费者例如 RTVI observer在不与内部 sequencer 状态耦合的情况下实现词级高亮。两个控制词是否被记录的标志是append_to_context按上下文在注册时设置整个上下文排除在转录之外与suppress_in_contexttracker 处于变换段中间时为真——这正是forty-two、dollars、and、fifty不会进入上下文、只有承载raw_text$42.50的cents会进入的原因。3.6 端到端code-helper 示例code-helper示例一次性验证了这套堆栈的所有部件。它的机器人提示 LLM 给输出打标签然后对每种标签类型做不同路由# 1. 聚合时把带标签的段单独切出 llm_text_aggregator.add_pattern( typecredit_card, start_patterncard, end_pattern/card, actionMatchAction.AGGREGATE ) # 2. 代码块绝不让 TTS 朗读 tts CartesiaTTSService(..., skip_aggregator_types[code]) # 3. 按段类型重写 TTS 收到的文本 tts.add_text_transformer(spell_out_text, credit_card) # 包裹 spell 标签 tts.add_text_transformer(strip_url_protocol, link) # 去掉 https:// # 4. 按段类型脱敏客户端渲染的内容 rtvi_observer_params RTVIObserverParams( bot_output_transforms[(credit_card, obfuscate_credit_card)] )这四个配置就产生了 §3.2 表格中的六个帧外加一个表格未展示的东西客户端渲染的是XXXX-XXXX-XXXX-3456而非真实数字——因为第 4 步在段外发时对其脱敏而高亮光标继续在脱敏后的形式上前进。客户端只需约 10 行渲染逻辑因为困难部分已在服务端完成。从 rtvi-integration 文档可以看到RTVIObserver如何把这些帧转成bot-output消息spoken_status走new→in-progress→completed三态生命周期其中completed恰在remaining 时发出是推导出的而非跟踪的脱敏变换函数接收整段文本加当前朗读切分accumulated_text/remaining_text按比例把高亮切分保持在脱敏后的形式上。4. 继续深入每层的独立文档每一层都有自己的文档含从真实代码追踪的完整示例文档内容TextSegmentMap对齐三份文本WordCompletionTracker跟踪一个帧到完成AggregatedFrameSequencer对下游帧排序RTVI 集成帧如何到达客户端可能的改进已知的毛糙边缘及其推理5. 测试这套机制有大规模测试覆盖各测试文件与覆盖范围如下文件测试数覆盖tests/test_text_segment_map.py66对齐、markup 辅助、hop 分类tests/test_word_completion_tracker.py203完成判定、span 归属、TTS 提供商怪癖tests/test_aggregated_frame_sequencer.py134槽位排序、流式、并发上下文tests/test_tts_frame_ordering.py46经真实服务的端到端帧顺序tests/test_cartesia_tts.py13Cartesia 词时间戳形态tests/test_soniox_tts.py11Soniox 词时间戳形态可运行以下命令验证整套行为适用前提已克隆本仓库并配置好uv环境uv run pytest tests/test_text_segment_map.py tests/test_word_completion_tracker.py \ tests/test_aggregated_frame_sequencer.py tests/test_tts_frame_ordering.py \ tests/test_cartesia_tts.py tests/test_soniox_tts.py6. 小结Pipecat 的 TTS 逐词跟踪架构用一个清晰的分工解决了一次 LLM 输出、三种文本需求的同步问题在产生阶段两个切分点aggregator 的raw_text/text拆分 TTS 服务的 filters/transformers保证 LLM 文本、段文本、TTS 文本各自独立变换只污染送往 TTS 的副本在播放阶段TextSegmentMap用 diff 多游标回答我们在哪里WordCompletionTracker回答这个词归属谁、帧完成了吗AggregatedFrameSequencer用有序槽位队列回答帧以什么顺序离开。三层之上由TTSService统一接线最终每个被朗读的词都产出一个TTSTextFrame保标签、保顺序地回到上下文和一个AggregatedTextProgressFrameaccumulated_text remaining_text frame.text的精确切分使词级高亮、敏感信息脱敏、转录记录等能力成为客户端的简单消费问题。【免费下载链接】pipecatOpen Source framework for voice agents, multimodal apps, and realtime AI. Maintained by Daily and the community.项目地址: https://gitcode.com/GitHub_Trending/pi/pipecat创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价