让首个声音提前 — 分句·重叠合成·可朗读文本
目标
测量 TTS 的 RTF 和第一个声音的延迟,按句子、从句切开并与 LLM 流重叠合成,把给屏幕看的文字变成给耳朵听的文字,并用往返 WER 确认是否更容易听懂。
为什么重要
用户等待的不是整个回答,而是第一个声音。第一个声音由第一个片段的长度和合成速度决定。而且 TTS 是按书写的样子来读的,所以在屏幕上完好的“14:30 [kb-hours]”,到了耳朵里会变得离谱。评分器不会按采样点比较合成的 WAV(VITS 每次生成的波形都略有不同)。它看的是长度、RTF 和事件顺序的一致性,能不能听懂,则通过用 ASR 重新转写你的 WAV 来确认。
步骤
- 合成一句话并保存为
/root/voice/tts/hello.wav,把合成时间、长度和 RTF 写入/root/voice/tts/synth.json。 - 在
/root/voice/tts/split.py中创建不会在缩写和小数处出错的sentences(text)。 - 把整体合成三句话文本时和只合成第一句时的耗时,写入
/root/voice/tts/first_audio.json。 - 把 LLM 流切成片段并与 TTS 重叠合成,把事件时刻保存到
/root/voice/tts/stream.json,把拼接起来的声音保存到/root/voice/tts/stream_out.wav。 - 在
/root/voice/tts/normalize.py中创建把引用标记、24 小时制时间、长数字、金额和缩写变成朗读文本的speakable(text)。 - 把三个句子分别以原文原样和朗读文本合成,用 ASR 转写,写入
/root/voice/tts/roundtrip.json。 - 把
stream_out.wav转换成 16 kHz s16le 20 ms 帧,生成/root/voice/tts/tts_stream.pcm和/root/voice/tts/tts_stream.json。 - 创建汇总了测量结果的
/root/voice/tts/report.json。
参考
- 模型:
from voicekit.models import tts→t = tts();a = t.generate(text, sid=0, speed=1.0);声音是a.samples(float 列表),采样率是a.sample_rate(22050)。 - 第一次调用会混入准备时间,所以很慢。测量之前,请先合成一小段文字。
- 重采样:
voicekit.resample_linear(x, sr_in, sr_out)。输入 ASR 时,要在前面加上 0.3 秒静音,并按 100 ms 的片段输入——TTS 的声音从第一个采样点就开始说话,所以如果不加静音输入,就会漏掉第一个词(实测)。 - 常见错误:把模型加载时间算进合成时间,每个句号都切开,把 22050 Hz 的声音写成 16000 发送出去。
- 文档:Piper voices(ljspeech 模型卡) · VITS(Kim et al., 2021) · LJ Speech · sherpa-onnx
合成一句话并测量 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)。