LabHub
开始
学习 学习路径 课程

语音 AI 智能体 — 会听、会查、会说的流水线

让首个声音提前 — 分句·重叠合成·可朗读文本

在 LabHub 中继续学习

目标

测量 TTS 的 RTF 和第一个声音的延迟,按句子、从句切开并与 LLM 流重叠合成,把给屏幕看的文字变成给耳朵听的文字,并用往返 WER 确认是否更容易听懂。

为什么重要

用户等待的不是整个回答,而是第一个声音。第一个声音由第一个片段的长度和合成速度决定。而且 TTS 是按书写的样子来读的,所以在屏幕上完好的“14:30 [kb-hours]”,到了耳朵里会变得离谱。评分器不会按采样点比较合成的 WAV(VITS 每次生成的波形都略有不同)。它看的是长度、RTF 和事件顺序的一致性,能不能听懂,则通过用 ASR 重新转写你的 WAV 来确认。

步骤

  1. 合成一句话并保存为 /root/voice/tts/hello.wav,把合成时间、长度和 RTF 写入 /root/voice/tts/synth.json。
  2. 在 /root/voice/tts/split.py 中创建不会在缩写和小数处出错的 sentences(text)。
  3. 把整体合成三句话文本时和只合成第一句时的耗时,写入 /root/voice/tts/first_audio.json。
  4. 把 LLM 流切成片段并与 TTS 重叠合成,把事件时刻保存到 /root/voice/tts/stream.json,把拼接起来的声音保存到 /root/voice/tts/stream_out.wav。
  5. 在 /root/voice/tts/normalize.py 中创建把引用标记、24 小时制时间、长数字、金额和缩写变成朗读文本的 speakable(text)。
  6. 把三个句子分别以原文原样和朗读文本合成,用 ASR 转写,写入 /root/voice/tts/roundtrip.json。
  7. 把 stream_out.wav 转换成 16 kHz s16le 20 ms 帧,生成 /root/voice/tts/tts_stream.pcm 和 /root/voice/tts/tts_stream.json。
  8. 创建汇总了测量结果的 /root/voice/tts/report.json。

参考

合成一句话并测量 RTF

创建模型,用一小段文字预热一次之后,合成“Your appointment is on Tuesday at ten in the morning.”,保存到 /root/voice/tts/hello.wav(22050 Hz 单声道),并在 /root/voice/tts/synth.json 中写入 sample_rate、synth_ms(generate 所花的时间)、audio_s、rtf(synth_ms/1000/audio_s)。

只用 time.perf_counter() 包住 generate 调用。如果 RTF 小于 1,说明生成得比说话的速度更快。如果算入模型加载(1–2 秒),RTF 看起来就会超过 1。

不会出错的句子切分

在 /root/voice/tts/split.py 中创建 sentences(text)。把空白折叠成一个之后,只有在 .!? 之后跟着空格和大写字母(或文本结束)时才切开,但在“Dr.”“Mr.”“Mrs.”“Ms.”“St.”“No.”之后不切。把片段连起来必须与原文相同,空文本则是空列表。评分器会用隐藏用例进行测试。

用 re.finditer(r".!?", text) 找出候选,如果候选之前的词在缩写列表中,就跳过。“$3.50”的句号之后是数字,所以不成为候选。

整体对按句——到第一个声音为止

把“Please arrive fifteen minutes before your visit. Bring a photo ID and your insurance card. If you need to cancel, call at least one day before.”整体合成的时间(whole_ms)、用 split.py 切分后只合成第一句话的时间(chunked_ms)、以及句子数(sentences),写入 /root/voice/tts/first_audio.json。

整体合成时,必须全部生成完才能发出第一个声音;按句合成,只需生成第一句。两者都用预热过的模型来测量。

与 LLM 重叠合成

在 voice-llm up 之后,用 voicekit.llm.chat_stream(msgs, max_tokens=80) 接收“You are a clinic phone assistant. Answer in two or three short sentences.”和“How do I prepare for a fasting blood test?”,每当文字累积,就切出前面的片段(如果句子结束了就取句子,否则取超过五个词的逗号之前的从句,再不行就取十二个词),通过队列交给 TTS 线程。把事件 llm_first_token、chunk(交出的片段,含 text)、audio(合成完毕的片段,含 audio_s)、llm_done 用同一个时钟写入 /root/voice/tts/stream.json,并把声音按顺序拼接,保存到 /root/voice/tts/stream_out.wav(22050 Hz)。

TTS 用 threading.Thread 单独运行,并通过 queue.Queue 接收片段。最后放入 None 来结束线程,并执行 join。片段必须有两个以上才会产生重叠——即使小模型只用一句话回答,也可以按逗号或词数来切。

把给屏幕看的文字变成给耳朵听的文字

在 /root/voice/tts/normalize.py 中创建 speakable(text)。删去引用标记 [kb-…],把“H:MM a.m./p.m.”和 24 小时制的“HH:MM”改成口语(14:30 → two thirty p m,09:00 → nine o'clock a m),三位以上的数字逐位朗读(911 → nine one one),“$80”改成 eighty dollars,其余一两位的数字改成词,“Dr.”改成 Doctor,“a.m.”“p.m.”改成“a m”“p m”。评分器会用隐藏用例进行测试。

顺序很重要。必须先改时间(9:30),数字规则才不会把 9 和 30 分开读。用一个把 0–99 变成口语的小函数,就能同时用于时间、金额和数字。

用 TTS → ASR 往返测量可懂度

把“Your visit is at 14:30 [kb-hours].”·“Call 911 right away.”·“A visit costs $80.”分别以原文原样(raw)和用 speakable 改写后(norm)合成,转换成 16 kHz 并在前面加上 0.3 秒静音,保存为 /root/voice/tts/rt{i}_{kind}.wav(i 为 1 到 3),然后以每次 100 ms 输入流式 ASR 转写。在 /root/voice/tts/roundtrip.json 中写入六行 {"id", "kind", "tts_input", "heard", "meant"}(meant 为 speakable(原文))。

把 ASR 当作听的人,来代替人耳。评分器会重新转写你的 WAV,并按 raw 和 norm 比较与 meant 的 WER。片段大小不同,流式结果可能略有变化,所以统一用 100 ms。

转换成传输格式

用 voicekit.resample_linear 把 stream_out.wav(22050 Hz)转换成 16 kHz,连续写成 s16le 20 ms(320 个采样点)帧的 /root/voice/tts/tts_stream.pcm(最后一帧用 0 补齐),并生成写有 format、sample_rate、channels、frame_ms、frame_bytes、frames 的 /root/voice/tts/tts_stream.json。

与模块 1 的最后一步形态相同。如果把 22050 直接当作 16000 发送,就会变成慢 1.38 倍、低沉的声音。

报告

在 /root/voice/tts/report.json 中写入 rtf(synth.json)、whole_ms、chunked_ms(first_audio.json),以及 stream.json 中各事件第一次出现的时刻 llm_first_token_ms、first_audio_ms(第一个 audio)、llm_done_ms。

从前往后读取事件列表,对每个名称只保留第一次看到的时刻(dict.setdefault)。