本文说明 synthesized_duplex_v4 这份数据由哪些设计特性构成、每个特性在文本侧和音频侧分别怎么落地、以及它们在最终产物里长什么样。
duplex_state / interaction_intent 标签。产物形态:
{id}_stereo.wav 双声道成品(左=user+third,右=assistant)
{id}_timeline.json 逐段时间轴 + 文本 + 标签
左轨是用户(含第三方、附和、自语),右轨是助手。两侧可以同时发声——这就是"全双工"。
v4 相对 v2 的三处变化(本文其余部分按 v4 描述):
| 变化 | v2 | v4 |
|---|---|---|
| 附和 / 自语 | 每轮最多 1 条 | 同一轮最多 3 条,可落在不同 anchor |
| 长停顿的音频实现 | 切一条 wav,插入数字静音 | 两段式合成:按标记劈成两条独立 TTS,停顿区铺同说话人底噪 |
子句 / 线索片段的 interaction_intent |
几乎全部 accept |
按"助手能否在此处接话"重新判定,中途子句与线索片段改为 reject |
这套数据的每一条特性都同时有文本载体和音频处理两个面。下表是全貌,后续逐条展开。
| # | 特性 | 文本侧怎么产生 | 音频侧怎么处理 | 关键字段 |
|---|---|---|---|---|
| 1 | 口语化 | LLM 改写 user 文本,插入填充词、打散书面语 | 直接送 TTS,无需特殊处理 | user 文本本身 |
| 2 | 短停顿 | 填充词 嗯……/呃……/那个…… 写进文本 |
由 TTS 自然合成,时长不受控、不测量 | 文本里的 …… |
| 3 | 长停顿 | LLM 在语义不完整处插入 [Nms] 标签 |
按标记劈成左右两段分别合成,中间铺同说话人底噪 | [Nms] |
| 4 | 子句切分 | LLM 判断"听到哪里助手就能接话",切成子句 | user 段按子句拆成多片摆放 | user_clause |
| 5 | 打断 | Stage 1 让 user 掐断 assistant,生成期真截断文本 | 合成 assistant_full 再按字跨度截断;下一轮提前叠入 |
char / assistant_full / interrupt_form |
| 6 | 连续指令 | LLM 生成 3–5 条连续指令,每条带间隔 | 按 tail_gap_ms 决定助手是否发声、在哪被切 |
interrupt_form: sequential / tail_gap_ms |
| 7 | 附和 / 自语 | LLM 在一轮的 assistant 话语里挑 1–3 个落点,各插一条 backchannel / self_talk |
每条各自在 anchor 终点起声,与该轮 assistant 后半段跨轨重叠 | events[](同轮可多条) |
| 8 | 第三方 | LLM 插入第三方插话、用户回应、被打断后的重述 | 第三方在左轨;用户可能让出子句边界再回来 | third_insert / address_third / response_to_third / resumption |
这八项的分工可以这样理解:1–2 让说话像真人;3–4 让说话的节奏和语义边界可测;5–6 制造抢话;7–8 制造多说话人交叠。4 是 5–6 之外的第三种"助手何时能接话"信号,也是唯一显式落在文本上的。
做什么:把书面化的问句改写成真人边想边说时的样子。
改写前:那从执行难度考虑,垃圾分类最容易在哪个环节出错?
改写后:那从执行难度上讲,垃圾分类这事儿,嗯……最容易在哪个环节出错?
约束:只改 user,绝不动 assistant;保留核心意图、事实细节、情绪;不得机械化堆砌(不能变成结巴)。
为什么重要:TTS 若拿书面文本合成,韵律会偏朗读腔;口语化后的文本自带停顿线索,合成出来更接近真实对话。
形式:直接写进文本的语气词——嗯…… / 呃…… / 那个…… / 就是…… / ……。
特点:时长由 TTS 决定,不受控、不测量。这是它的优势(自然)也是它的局限(不能表达确定性时长)。
与长停顿的分工:短停顿管句内的短犹豫;长停顿管语义不完整处的长静音。两者可以相邻。
形式:文本里的内联标签 [600ms] / [800ms] / [1200ms]。
"text": "为什么王者荣耀里,老有人觉得……典韦打野比[600ms]对抗路强?"
判据:标签代表说话人卡在某个词上、正在把它想出来,不是"说完一个完整意思后的停顿"。
我想买一辆大概[800ms]50万的车(修中之间,语义未完整)…哪个环节[800ms]出错?("哪个环节"是固定搭配,真人不会从中间断开);…电动车[800ms],到底哪个更值得入手?(前半已构成完整意思,助手此时就能回答)档位:只能从配置的离散档位里选(pause_tiers_ms,当前批次实际出现 600 / 800)。
v2 的做法是"合成一整句 → 对齐 → 在标记处切开 → 插 N ms 数字静音"。问题在于切点两侧都是同一条 TTS 的中间态:左半句没有句末语调,右半句没有起音,中间再接一段电平为 0 的静音,听起来像信号掉了。
v4 改为把标记当作段落切分点:
文本:家里那盒[800ms]是不是也有?
↓ 按标记劈开
左片段:家里那盒 右片段:是不是也有?
↓ 各自独立合成(同一 speaker prompt)
L.wav(带句末收束) R.wav(带自然起音)
↓ 规划
[ L.wav ][ 停顿区 ][ R.wav ]
要点:
标签不送 TTS。左右两段各自合成时都已是干净文本。
左片段以非终句语调收尾。实测停顿前 200 ms 的 F0 斜率:v2 为 −4.4 Hz/10ms(下行,像句末),v4 为 +4.3 Hz/10ms(上行/持平,像话没说完)——这正是"卡在想词"该有的形态。
停顿区不再是数字静音。v4 的停顿区是一层低电平底噪,而不是零:
| 指标(10 条样本 / 55 处) | v2 切一条插零 | v4 两段式 |
|---|---|---|
| 停顿区电平 | −103 dBFS | −89 dBFS(同说话人最安静 hop 平铺) |
| 精确零采样占比(中位) | 99% | 51% |
| 停顿区总长 vs 标记值 | N + 40 ms | N + 200 ~ 300 ms |
| 停顿区总长 p10–p90 | 604–816 ms | 546–1239 ms |
| 时间戳 | 按绝对秒,无误差 | 按绝对秒,无误差 |
时间轴上不再做字跨度平移。两段本来就是两条 wav,各自的 CharSpan 独立,不需要 attach_pauses 那种"切点之后全部 +N"的移位。
停顿区不填数字零,而是铺一段取自同一说话人真实合成的底噪。做法不是频谱建模,就是"抠一段最安静的波形,平铺进去":
取材:取同样本所有 slot == user 的波形(含当前这条)。不用 backchannel / self_talk,不用对齐结果。
筛 hop:按 20 ms 一帧计算 RMS,依次剔掉——
|x| < 1e-6 的帧(数字静音,包括我们自己插进去的 N ms 零);合格的帧首尾拼成模板,模板两端各做最多 5 ms 淡化,避免循环拼接时咔哒。音量不抬也不压,保持原始 RMS——所以不同样本的底噪电平天然不同,这是刻意的。
填回去:在带 [Nms] 的 wav 里找连续近零、长度 ≥ 80 ms 的区间,把模板 np.tile 铺满,两端再各做 30 ms 余弦淡入淡出。找不到合格 hop、或零区不够长,就保持静音。
实测结果(10 条样本 / 55 处标记):55 处全部铺上了模板,底噪 RMS 中位 −89 dBFS(p10 −104 / p90 −86),模板以 20 ms 为周期严格重复——这就是 np.tile 的直接证据。
timeline.json 记录的是每段的绝对起止秒,停顿区只是落在左段结束与右段开始之间,下游按 start_s/end_s 取用即可,不需要用标记值反推时间。标记值表达的是"说话人打算停多久",不承诺物理时长等于该数值。比[600ms]对抗路、又是……[800ms]从哪儿 这类落点。做什么:判断"用户说到哪个位置,语音助手已经获得足够开始回复的完整语义",在那里切成子句。
"user": "按面积分倒是合理,可要是家里地多但人少,或者地少人多,会不会觉得吃亏?真闹起来怎么办?",
"user_clause": [
"按面积分倒是合理,可要是家里地多但人少,或者地少人多,会不会觉得吃亏?",
"真闹起来怎么办?"
]
字段语义:
| 值 | 含义 |
|---|---|
[] |
该轮没有内部切点——助手必须听完整轮才能回复 |
[c1, c2, ...] |
助手可以在每个子句结束时开始接话 |
硬约束:子句按序拼接必须逐字符等于原 user 文本(标点归入前一个子句)。单元素列表等价于无切点,会被归一化成 []。
音频侧:user_clause ≥ 2 时,这段 user 会被切成多个 Placement 摆放,用 src_start_ms / src_end_ms 从同一条已含停顿的 user 音频里切片。片间间隔看这个边界有没有 [Nms],有就用它,否则按同说话人哈希间隔。
标签侧:同一轮拆出的多个片段,只有最后一个是 accept(助手可以接话),中间的片段一律 reject。见第三节。
为什么需要它:backchannel 的起声点靠 anchor 定位,而 anchor 是字级的;子句边界是语义级的。有了 user_clause,下游才知道"助手在哪几个位置抢话是合理的"。
形式:不产生 events 对象,而是写在被打断轮(前一轮)的 turn 字段上。
| 形态 | 字段 | 语义 |
|---|---|---|
| 字级打断 | char + assistant_full |
合成全文,在 char 处截断 |
| 线索打断 | interrupt_form: cue + cue_chars + assistant_full |
同上截断;下一轮 user 开头 cue_chars 个字独立成片,重叠量不超过该线索时长 |
关键点:assistant 是已截断的前缀文本,不参与合成——下游一律合成 assistant_full 再截。残句会被 TTS 收成完整句语调,听起来像"说完了"。
音频侧:切点取 anchor 首字之前最后一个字的 end,再放出 anchor 首字开头一小段做衰减尾(clamp(0.5 × 字长, 60, 200) ms),最后余弦淡出。下一轮 user 提前 1000 ms 叠入,制造抢话。
线索打断的细节:线索片段(稍等, / 停, / 等等,)独立成一个 Placement,其结束时刻与下一片段的起点首尾相接(间隔约 0 ms),并且比被打断的 assistant 晚结束约 300 ms——也就是说线索喊完之后,助手的话还在响一小段才被彻底盖住。本批 15 处线索打断全部如此。
做什么:在一处插入 3–5 条用户连续指令,每条带一个"与下一条之间的静音"。
| 间隔 | 助手行为 |
|---|---|
短(tail_gap_ms < 阈值) |
助手不发声,用户直接说下一条 |
长(tail_gap_ms ≥ 阈值) |
助手开口,但马上被下一条指令打断 |
指令1 ──[短间隔]── 指令2 ──[长间隔]── 指令3
↑ 助手刚开始说 就被指令3盖住
每条 assistant_full 回答"到它为止听到的全部指令"——因为助手开口前只听到了 1…i,不可能回答未来的指令。但写法上不是逐条罗列,而是把 1…i 当作一条复合指令自然作答。后续指令推翻前面的,以最新为准。
音频侧(场景 A′):
nstart = 当前 user.end + tail_gap_ms
若 tail_gap < 阈值:不合成 assistant,下一轮 user 直接放在 nstart
否则:合成 assistant_full,a.start = user.end + min(gap, tail_gap)
cut_end = nstart + interrupt_overlap
按"听到的时长"截断 assistant
下一轮 user 放在 nstart
短间隔时助手在时间轴上缺席**——这正是"助手还没来得及回答,用户就说下一条"的物理表达。
形式:Stage 2 挑轮次插入的 events 对象,place.owner = assistant、mode = overlap。
{
"event_type": "backchannel", "speaker": "user", "text": "这样啊……",
"place": {"owner": "assistant", "turn_idx": 2, "char": 46},
"anchor": "别被极端案例吓住", "response": null, "ignored": false
}
两者的区别:backchannel 是回应对方("嗯嗯"、"确实……");self_talk 是自言自语("太强了吧。")。合成时音色相同、风格参考不同(speed 1.2 vs 1.0)。
真人听一段较长的回复,不会只应一声——听到关键数字应一声、听到转折再嘀咕一句,是常态。因此 v4 允许同一轮承载多条事件:
| 约束 | 值 |
|---|---|
| 单轮上限 | 3 条 |
| 常见形态 | 1 条(多数);2 条(该轮 assistant 文本里有多个独立反应点) |
| 相邻 anchor 间隔 | ≥ 12 个字 |
| 同轮用词 | 不得重复(嗯…… 与 嗯哼…… 算不同) |
| 连轮限制 | 禁止连续两轮都放 ≥2 条 |
实测分布(v4,10 条样本):
| 每轮事件数 | 轮数 |
|---|---|
| 1 条 | 29 |
| 2 条 | 11 |
| 3 条 | 0(上限,不是目标) |
共 51 条事件(41 条 backchannel + 10 条 self_talk),分布在 40 个轮次里。11 个双事件轮次的构成:4 个 backchannel+backchannel,7 个 backchannel+self_talk。
音频侧:每条事件各自在自己的 anchor 终点起声——也就是"用户听完了那个子句才有反应"——与 assistant 后半段跨轨重叠。同轮两条事件落在同一段 assistant 话语内,彼此间隔实测 4.1–10.6 s(中位 5.7 s),不会挤在一起。
四种事件,构成"用户被第三方打扰"的完整链条:
| 事件 | 谁发声 | 语义 |
|---|---|---|
third_insert |
第三方 | 第三方插话,可能打断用户,也可能只在旁边说 |
address_third |
用户 | 用户主动招呼身边人 |
response_to_third |
用户 | 用户回第三方的话 |
resumption |
用户 | 打发走第三方后,重述自己刚才没说完的问题 |
为什么用 resumption 而不是"继续":用户被打断时,原问题的后半截没说完。回到助手这边时,他会重新起头把那句话说完——这是真实对话里非常典型的修补行为。样例:
t2 user:那……这种怀疑论对政策制定,或…(被第三方打断)
t2 third:会议室等你!
t2 user:我马上过去,先开始吧 ← response_to_third
t2 user:那……这种怀疑论对政策制定和国际合作,到底造成了多大的负面影响? ← resumption
t2 assistant:(正常回答)
音频侧:第三方总在左轨(与用户同轨)。insert_on_user/interrupt 时用户被掐断;overlap_after_event 时用户说完当前子句再停;ignore 时用户完全无视、自顾自说完。
duplex_state 与 interaction_intent这两个字段是这套数据面向训练的核心标签。它们不是对音频的客观描述,而是对"这一段在对话中扮演什么角色"的判定。
非 assistant 段(即 user 与 third 段)都带这两个字段;assistant 段不带。
speaker=user → 带标签
speaker=third → 带标签
speaker=assistant → 不带
v4 当前批次 468 段中,308 段带标签(300 user + 8 third)。
event_type |
speaker | duplex_state |
interaction_intent |
段数 |
|---|---|---|---|---|
main |
user | incomplete |
accept |
128 |
main |
user | incomplete |
reject |
42 |
interrupt |
user | interrupt |
accept |
15 |
interrupt |
user | interrupt |
reject |
15 |
interrupt |
user | incomplete |
accept |
1 |
sequential |
user | incomplete |
accept |
2 |
sequential |
user | incomplete |
reject |
21 |
sequential |
user | interrupt |
accept |
8 |
sequential |
user | interrupt |
reject |
3 |
backchannel |
user | backchannel |
reject |
41 |
self_talk |
user | backchannel |
reject |
10 |
resumption |
user | incomplete |
accept |
4 |
address_third |
user | incomplete |
reject |
2 |
response_to_third |
user | incomplete |
reject |
8 |
third_insert |
third | incomplete |
reject |
8 |
合计:incomplete 216、interrupt 41、backchannel 51;accept 158、reject 150。
duplex_state —— 这一段的主话语处于什么状态:
| 值 | 含义 |
|---|---|
incomplete |
主话语语义未完整,对方可以接话 |
interrupt |
这一段的说话人打断了对方,或与被打断的对方话语在时间上重叠 |
backchannel |
短插话,不承载主语义(backchannel 与 self_talk 都归此类) |
interaction_intent 的判定规则(v4 修正)v2 里这个问题片段几乎一律是 accept:同一轮被拆成两三个子句时,中间那些"话还没说完"的片段也标成了 accept;线索打断的前缀(稍等,)同样标成 accept。这会让下游误以为「助手在这里就可以接话」。
v4 改成按**「助手能否在这一段结束时接话」**判定:
accept,前面的都是 reject。response_to_third / address_third:恒为 reject——说话对象是身边的人,不是助手。resumption:accept——重述的是完整问题。interrupt 且 interrupt_cue = true:reject——用户只喊了一声"停",紧接着就续说,话轮没有交出去。interrupt 且无 cue:accept——用户已经夺走话轮。按此规则回标全量,140 / 142 处一致(98.6%)。
各处修正的实际效果:
| 场景 | v2 | v4 |
|---|---|---|
一轮中的中间子句(main) |
accept(48 处) |
reject(40 处) |
一轮中的最后子句(main) |
accept(41 处) |
accept(53 处) |
线索打断前缀(interrupt_cue) |
accept(3 处) |
reject(15 处) |
无 cue 的 interrupt |
accept |
accept |
duplex_state 本身在这些片段上没有改变(子句仍是 incomplete,打断仍是 interrupt),修正集中在 interaction_intent。
两处残留分歧:sample 0 t3 与 sample 6 t14 的末片段被判 reject。这两处都位于跨轮的连续指令 streak 内部——按"轮内最后一个片段"的判据应为 accept,按"streak 还没结束"的语义应为 reject,两种口径在 streak 中段会打架。另有 1 处 interrupt 的 duplex_state 为 incomplete(sample 6 t4,同位置有第三方插话重叠),是全批唯一的不一致点。
这两个字段是给下游训练用的:
duplex_state 让模型知道"此刻对方说完了没有"——incomplete 表示可以开始生成,interrupt 表示要处理抢话,backchannel 表示这是短插话不是主诉求。interaction_intent 区分"这一段的结束处,是不是一次可响应的交互落点"。也就是说,训练时模型看到的不只是音频,还有"这段音频在对话结构里是什么"。
timeline.json段落级时间轴,绝对秒、三位小数。
{
"id": 0,
"sample_rate": 16000,
"duration_s": 319.394,
"loudness_lufs": -23.0,
"segments": [ ... ],
"overlaps": [ ... ]
}
v4 当前批次:10 条样本,468 段(300 user / 160 assistant / 8 third),121 条 overlap 记录,单条时长中位 316 s。
segments[] 的字段:
| 字段 | 说明 |
|---|---|
start_s / end_s |
绝对秒,三位小数 |
track |
left / right |
speaker |
user / assistant / third |
turn_idx |
所属轮次 |
event_type |
见下表 |
text |
该段文本;user 段可含 [Nms] |
duplex_state / interaction_intent |
仅非 assistant 段 |
truncated / cut_s / cut_reason |
被打断的 assistant 段 |
interrupt_cue |
仅线索前缀片为 true |
event_type 全表:main interrupt sequential third_insert address_third response_to_third resumption backchannel self_talk。
字跨度不进 timeline,留在 intermediate/{id}/{turn}/{slot}.npz。
下面对 9_timeline.json 逐段标注,说明每一段命中哪项特性。这条样本 281 秒、45 段、16 轮,覆盖了除 address_third 之外的全部特性(address_third 的示例取自 sample 8)(下表只列命中特性的段,不是全部 45 段)。
| # | 时间 | 轨 | 说话人 | t | 特性 | duplex_state/intent |
|---|---|---|---|---|---|---|
| 0 | 0.569–6.537 | L | user | 0 | 口语化 + 短停顿 | incomplete/accept |
| 1 | 7.664–20.087 | R | assistant | 0 | 正常回复 | — |
| 2 | 13.134–14.484 | L | user | 0 | 附和 原来如此…… |
backchannel/reject |
| 3 | 20.627–25.999 | L | user | 1 | 长停顿 [600ms] |
incomplete/accept |
| 4 | 26.967–32.517 | R | assistant | 1 | 被截断(interrupt) |
— |
| 5 | 31.517–33.837 | L | user | 2 | 打断(无 cue,提前 1.0 s 叠入) | interrupt/accept |
| 6 | 33.837–37.241 | L | user | 2 | 长停顿 [800ms] |
incomplete/accept |
| 10 | 59.489–60.179 | L | user | 3 | 附和 ① 确实…… |
backchannel/reject |
| 11 | 64.289–65.159 | L | user | 3 | 自语 ② 这么多?! |
backchannel/reject |
| 13 | 77.371–83.981 | R | assistant | 4 | 被截断 | — |
| 14 | 82.981–87.660 | L | user | 5 | 打断(无 cue) | interrupt/accept |
| 16 | 99.673–101.593 | L | user | 6 | 子句 ① | incomplete/reject |
| 17 | 101.817–105.804 | L | user | 6 | 子句 ② + 长停顿 [600ms] |
incomplete/accept |
| 19 | 113.622–119.868 | L | user | 7 | 打断(无 cue) | interrupt/accept |
| 21 | 133.729–136.829 | L | user | 8 | 子句 ① | incomplete/reject |
| 22 | 137.253–140.864 | L | user | 8 | 子句 ② | incomplete/accept |
| 23 | 137.513–139.347 | L | third | 8 | 第三方插话 会议室等你呢 |
incomplete/reject |
| 24 | 141.199–142.557 | L | user | 8 | 回应第三方 行,我马上过去 |
incomplete/reject |
| 25 | 142.912–147.962 | L | user | 8 | 重述(重新起头把问题说完) | incomplete/accept |
| 27 | 159.533–163.062 | L | user | 9 | 连续指令 1 | incomplete/reject |
| 28 | 163.851–166.603 | L | user | 10 | 连续指令 2 | incomplete/reject |
| 29 | 167.468–169.395 | R | assistant | 10 | 助手开口即被切(只说 建议选在晚上进去打) |
— |
| 30 | 168.395–172.528 | L | user | 11 | 连续指令 3(打断助手) | interrupt/accept |
| 32 | 190.374–195.677 | L | user | 12 | 子句 ① + 长停顿 [800ms] |
incomplete/reject |
| 33 | 196.076–200.331 | L | user | 12 | 子句 ②(本轮末句) | incomplete/accept |
| 35 | 215.670–217.230 | L | user | 13 | 子句 ① | incomplete/reject |
| 36 | 217.536–221.531 | L | user | 13 | 子句 ② | incomplete/accept |
| 38 | 235.884–236.814 | L | user | 13 | 附和 这倒是…… |
backchannel/reject |
| 44 | 277.779–278.669 | L | user | 15 | 附和 明白了 |
backchannel/reject |
从这条样本能读出的几件事:
truncated: true),段 5 提前 1.0 s 叠入(overlaps 里有一条 both)。incomplete/reject,助手不发声);段 29 是助手对 t10 的回复,只说了 建议选在晚上进去打 就被段 30(t11)盖住。[Nms](段 3、6、17,另有一处在段 32 的子句①),都落在语义未完整处。reject:段 16 / 段 17、段 21 / 段 22、段 35 / 段 36 都是前一句 reject、后一句 accept。下表把八项特性落到具体的音频局面上:每个局面一行,右侧是可直接播放的示例(HTML 版每行附播放器与该片的逐段时间表,含 duplex_state / interaction_intent)。
| # | 局面 | 左轨 | 右轨 | 重叠 | 示例音频 |
|---|---|---|---|---|---|
| A | 字级打断 | 下一轮 user 整段,提前 1000ms 叠入 | 合成 assistant_full 后在 char 处截断 |
跨轨,约衰减尾长 | ▶ a_stage1_interrupt.wav |
| A2 | 线索打断 | user 先喊「停」独立成片,再续说 | 同上截断 | 跨轨,且 ≤ 线索时长 | ▶ a2_cue_interrupt.wav |
| A' | 连续指令 | 下一条指令在 user.end + tail_gap_ms 起声 |
短间隔时不发声;长间隔时开口即被切 | 跨轨,1000ms | ▶ a3_sequential_burst.wav |
| B1 | backchannel(单条) |
事件在 anchor 终点起声 | 整段播完 | 跨轨(与后半重叠) | ▶ b1_backchannel.wav |
| B1' | 同一轮两条 | 两条各自在自己的 anchor 终点起声 | 整段播完 | 跨轨 ×2 | ▶ b1b_multi_backchannel.wav |
| B2 | self_talk |
同上,语气与语速不同 | 整段播完 | 跨轨 | ▶ b2_self_talk.wav |
| C | 第三方插话 | third 在左轨说 | 整段播完(助手免疫) | 与 user 同轨 | ▶ c_third_insert.wav |
| D | 招呼第三方 | user 主动对身边人说 | 整段播完 | 跨轨 | ▶ d_address_third.wav |
| E | 回应第三方 | user 简短答复后回到原话题 | 整段播完 | 与 user 同轨 | ▶ e_response_to_third.wav |
| F | 重述 | user 被第三方打断后重新起头 | 整段播完 | 与 user 同轨 | ▶ f_resumption.wav |
| G | 长停顿 | user 文本含 [600ms],两部分独立合成,停顿区铺同说话人底噪 |
整段播完 | — | ▶ g_long_pause.wav |
示例音频取自
audio/clips/,每条截取「事件轮 + 前后各一轮」共三轮,双声道即最终产物格式(左 = user + third,右 = assistant)。 高亮行为事件段;标「✂ 截断」的是被打断而截断的主语流段。表格同时列出该片的duplex_state/interaction_intent——assistant 段没有这两个字段,这本身就是一条信息。
每条示例截取「事件轮 + 前后各一轮」共三轮,双声道即最终产物格式 (左 = user + third,右 = assistant)。表格中高亮行为事件段, 标 ✂ 截断 的是被打断而截断的主语流段; 浅蓝底的是同一轮里并列的多条附和——它们各自挂在该轮 assistant 话语的 不同 anchor 上(v4 新增)。
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–5.12s | user t3 | main | incomplete accept | 驻军数量对前期守城影响大吗?还是说……只要有钱随便招几个人就行? |
| 5.74–17.02s | assistant t3 | main | — | 影响极大,驻军少容易被流寇直接攻陷。前期别贪多,把核心封地兵力维持在两百左右,确保能守住即可。 |
| 10.23–10.92s | user t3 | backchannel | backchannel reject | 确实…… |
| 15.03–15.90s | user t3 | self_talk | backchannel reject | 这么多?! |
| 17.85–27.04s | user t4 | main | incomplete accept | 两百驻军听起来挺费钱的,前期根本攒不出这么多兵。嗯……有没有什么省事的办法能快速提升[800ms]防御力,不用一直往城里砸钱? |
| 28.11–34.72s | assistant t4 | main ✂ 截断 | — | 别硬堆步兵,去附近招几个骑兵或弩手,性价比最高。另外升级 |
| 33.72–38.40s | user t5 | interrupt | interrupt accept | 骑兵那么贵,前期哪来的钱去招,就是说……去城镇招募还是野外捡漏? |
| 39.32–49.51s | assistant t5 | main | — | 去城镇招募站花钱买,野外捡漏不靠谱。前期攒够五千金,招十个精锐骑兵就能大幅提升守城能力。 |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–4.70s | user t0 | main | incomplete accept | 人眼在完全无光的环境下,嗯……最远能看到多远的星星? |
| 6.10–10.64s | assistant t0 | main ✂ 截断 | — | 裸眼极限视星等约6.5等,对应距离 |
| 9.74–10.34s | user t1 | interrupt 线索 | interrupt reject | 打住, |
| 10.34–11.76s | user t1 | main | incomplete reject | 呃……6.5等是什么意思? |
| 12.25–15.18s | user t1 | main | incomplete accept | 是不是数字[600ms]越小越亮? |
| 16.04–24.64s | assistant t1 | main | — | 对,数字越小越亮。1等星比6等星亮约100倍,这是天文学的标准亮度分级。 |
| 25.52–31.91s | user t2 | main | incomplete accept | 那按这个标准算下来,最远能瞅见的星星,离地球大概[800ms]多少光年? |
| 33.25–42.42s | assistant t2 | main ✂ 截断 | — | 视星等取决于亮度而非距离。肉眼可见的最远恒星是半人马座阿尔法星,约4.4 |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–3.78s | user t9 | sequential | incomplete reject | 打营地好像有人数限制,呃……我带大部队过去有用吗? |
| 4.57–7.32s | user t10 | sequential | incomplete reject | 那……白天打还是晚上打有区别没? |
| 8.19–10.11s | assistant t10 | sequential ✂ 截断 | — | 建议选在晚上进去打 |
| 9.11–13.24s | user t11 | sequential | interrupt accept | 最后见到的强盗头目,嗯……是跟他单挑还是直接群殴划算? |
| 14.07–30.45s | assistant t11 | sequential | — | 进营地只能带少数高阶精锐,挑选黑夜突袭能降低敌人视野;面对最终头目直接拒绝单挑、下令全员围攻最稳,单挑一旦失误暴毙不仅全功尽弃还会被俘虏,群殴拿稳奖励即可。 |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–5.61s | user t3 | interrupt | interrupt accept | 我就想问那个大概多少光年的数,呃……别整[600ms]这些名字。 |
| 6.66–9.64s | assistant t3 | main | — | 大约4.2光年。 |
| 10.30–14.39s | user t4 | main | incomplete accept | 那除了这种近处的恒星,肉眼……能看到更遥远的星系吗? |
| 15.26–25.32s | assistant t4 | main | — | 能。肉眼可见最远的是仙女座星系,距离约250万光年,这是人类视力能触及的宇宙边界。 |
| 21.79–22.66s | user t4 | backchannel | backchannel reject | 真的假的…… |
| 25.81–32.46s | user t5 | main | incomplete accept | 250万光年?那咱们头顶上那堆亮闪闪的星星,呃……跟这比起来岂不是都算近亲? |
| 33.53–39.42s | assistant t5 | main ✂ 截断 | — | 没错,头顶那些其实都是太阳系隔壁的邻居,距离不过 |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–2.57s | user t2 | interrupt | interrupt accept | 人口高具体看地图上的哪个数字? |
| 2.57–5.97s | user t2 | main | incomplete accept | 呃……那个小人图标[800ms]代表啥意思? |
| 7.33–17.69s | assistant t2 | main | — | 地图城镇详情里“人口”那一栏的数字就是。小人图标通常指驻军数量,跟人口无关,前期别搞混。 |
| 18.25–23.11s | user t3 | main | incomplete accept | 驻军数量对前期守城影响大吗?还是说……只要有钱随便招几个人就行? |
| 23.73–35.02s | assistant t3 | main | — | 影响极大,驻军少容易被流寇直接攻陷。前期别贪多,把核心封地兵力维持在两百左右,确保能守住即可。 |
| 28.22–28.91s | user t3 | backchannel | backchannel reject | 确实…… |
| 33.02–33.89s | user t3 | self_talk | backchannel reject | 这么多?! |
| 35.84–45.04s | user t4 | main | incomplete accept | 两百驻军听起来挺费钱的,前期根本攒不出这么多兵。嗯……有没有什么省事的办法能快速提升[800ms]防御力,不用一直往城里砸钱? |
| 46.10–52.71s | assistant t4 | main ✂ 截断 | — | 别硬堆步兵,去附近招几个骑兵或弩手,性价比最高。另外升级 |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–5.26s | user t8 | main | incomplete accept | 那咱自己所在的银河系,嗯……从外面看[800ms]长啥样? |
| 5.89–16.74s | assistant t8 | main | — | 从外部看,银河系像个巨大的漩涡盘,中间有隆起的核球,边缘延伸出旋臂,整体扁平,直径约十万光年。 |
| 13.43–14.02s | user t8 | backchannel | backchannel reject | 哦…… |
| 17.39–23.95s | user t9 | main | incomplete accept | 十万光年……那咱们人类现在探测到的可观测宇宙边界,跟银河系这尺寸……差了多少倍? |
| 25.21–36.40s | assistant t9 | main | — | 差了约一百亿倍。可观测宇宙半径约465亿光年,银河系直径仅10万光年,人类还困在极小的局部区域。 |
| 30.52–31.63s | user t9 | self_talk | backchannel reject | 太夸张了吧。 |
| 36.15–36.90s | user t9 | backchannel | backchannel reject | 这样啊…… |
| 37.29–43.03s | user t10 | sequential | incomplete reject | 那按这个尺度看,我们观测极其遥远的天体,看到的……其实都是它们过去的模样吧? |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–6.50s | user t7 | interrupt | interrupt accept | 我就想问下,地图上怎么一眼认出哪些是流寇营地,别到时候……打半天全是杂兵没奖励。 |
| 7.93–19.83s | assistant t7 | main | — | 看地图图例,带骷髅头或帐篷标记的点才是营地。那些只有小兵图标的只是巡逻队,打了没奖励,直接跳过找营地。 |
| 20.36–23.46s | user t8 | main | incomplete reject | 那清完一个营地之后,会不会马上刷新新的出来? |
| 23.88–27.49s | user t8 | main ✂ 截断 | incomplete accept | 我得……规划下路线,不然一天到晚在地图边上转悠太浪费时间了。 |
| 24.14–25.98s | third t8 | third_insert | incomplete reject | 会议室等你呢 |
| 27.83–29.18s | user t8 | response_to_third | incomplete reject | 行,我马上过去 |
| 29.54–34.59s | user t8 | resumption | incomplete accept | 就是……清营地那事,会不会马上刷新新的出来,我得先把路线排好。 |
| 36.12–45.39s | assistant t8 | main | — | 会刷新,但需要几小时游戏时间。建议按区域集中清理,清完一片再换地方,避免无意义跑图。 |
| 46.16–49.69s | user t9 | sequential | incomplete reject | 打营地好像有人数限制,呃……我带大部队过去有用吗? |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–0.61s | user t6 | interrupt 线索 | interrupt reject | 等等, |
| 0.61–6.47s | user t6 | main | incomplete accept | 我是说除了太阳这颗恒星之外,周围那些……肉眼可见的恒星,不是指行星啥的。 |
| 7.53–22.64s | assistant t6 | main | — | 对,就是排除行星。那些亮星最近的是半人马座α,约4.2光年;最远的肉眼可见恒星在几千光年内,跟250万光年的仙女座比确实都是近邻。 |
| 23.28–26.22s | user t7 | main | incomplete accept | 那……仙女座星系里到底有多少颗恒星? |
| 27.21–34.98s | assistant t7 | main | — | 估计在一千亿到四千亿颗之间,大概是银河系恒星数量的三分之一。 |
| 32.02–33.93s | user t7 | address_third | incomplete reject | 帮我把窗户关一下。 |
| 35.87–40.88s | user t8 | main | incomplete accept | 那咱自己所在的银河系,嗯……从外面看[800ms]长啥样? |
| 41.51–52.35s | assistant t8 | main | — | 从外部看,银河系像个巨大的漩涡盘,中间有隆起的核球,边缘延伸出旋臂,整体扁平,直径约十万光年。 |
| 49.04–49.63s | user t8 | backchannel | backchannel reject | 哦…… |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–6.50s | user t7 | interrupt | interrupt accept | 我就想问下,地图上怎么一眼认出哪些是流寇营地,别到时候……打半天全是杂兵没奖励。 |
| 7.93–19.83s | assistant t7 | main | — | 看地图图例,带骷髅头或帐篷标记的点才是营地。那些只有小兵图标的只是巡逻队,打了没奖励,直接跳过找营地。 |
| 20.36–23.46s | user t8 | main | incomplete reject | 那清完一个营地之后,会不会马上刷新新的出来? |
| 23.88–27.49s | user t8 | main ✂ 截断 | incomplete accept | 我得……规划下路线,不然一天到晚在地图边上转悠太浪费时间了。 |
| 24.14–25.98s | third t8 | third_insert | incomplete reject | 会议室等你呢 |
| 27.83–29.18s | user t8 | response_to_third | incomplete reject | 行,我马上过去 |
| 29.54–34.59s | user t8 | resumption | incomplete accept | 就是……清营地那事,会不会马上刷新新的出来,我得先把路线排好。 |
| 36.12–45.39s | assistant t8 | main | — | 会刷新,但需要几小时游戏时间。建议按区域集中清理,清完一片再换地方,避免无意义跑图。 |
| 46.16–49.69s | user t9 | sequential | incomplete reject | 打营地好像有人数限制,呃……我带大部队过去有用吗? |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–6.50s | user t7 | interrupt | interrupt accept | 我就想问下,地图上怎么一眼认出哪些是流寇营地,别到时候……打半天全是杂兵没奖励。 |
| 7.93–19.83s | assistant t7 | main | — | 看地图图例,带骷髅头或帐篷标记的点才是营地。那些只有小兵图标的只是巡逻队,打了没奖励,直接跳过找营地。 |
| 20.36–23.46s | user t8 | main | incomplete reject | 那清完一个营地之后,会不会马上刷新新的出来? |
| 23.88–27.49s | user t8 | main ✂ 截断 | incomplete accept | 我得……规划下路线,不然一天到晚在地图边上转悠太浪费时间了。 |
| 24.14–25.98s | third t8 | third_insert | incomplete reject | 会议室等你呢 |
| 27.83–29.18s | user t8 | response_to_third | incomplete reject | 行,我马上过去 |
| 29.54–34.59s | user t8 | resumption | incomplete accept | 就是……清营地那事,会不会马上刷新新的出来,我得先把路线排好。 |
| 36.12–45.39s | assistant t8 | main | — | 会刷新,但需要几小时游戏时间。建议按区域集中清理,清完一片再换地方,避免无意义跑图。 |
| 46.16–49.69s | user t9 | sequential | incomplete reject | 打营地好像有人数限制,呃……我带大部队过去有用吗? |
| 时间 | 说话人 | 类型 | duplex_state interaction_intent | 文本 |
|---|---|---|---|---|
| 0.25–6.22s | user t0 | main | incomplete accept | 我刚开始玩骑马与砍杀2,嗯……完全不知道前期该干嘛,有没有什么新手入坑的建议? |
| 7.34–19.77s | assistant t0 | main | — | 先别急着打仗,去领个封地或者找个城镇打工攒钱。前期主要任务是刷声望和买装备,别乱接任务,否则容易被围殴。 |
| 12.81–14.16s | user t0 | backchannel | backchannel reject | 原来如此…… |
| 20.31–25.68s | user t1 | main | incomplete accept | 那个……封地到底怎么选啊,我看地图上好多地方,有啥[600ms]讲究没? |
| 26.65–32.20s | assistant t1 | main ✂ 截断 | — | 优先选离出生地近、人口高且特产好的城堡或村 |
| 31.20–33.52s | user t2 | interrupt | interrupt accept | 人口高具体看地图上的哪个数字? |
| 33.52–36.92s | user t2 | main | incomplete accept | 呃……那个小人图标[800ms]代表啥意思? |
| 38.27–48.64s | assistant t2 | main | — | 地图城镇详情里“人口”那一栏的数字就是。小人图标通常指驻军数量,跟人口无关,前期别搞混。 |
六个阶段串联,每阶段产出下一阶段的输入:
话题文件
→ ① 主对话骨架 生成 10–15 轮问答,按比例埋入打断
→ ② 连续指令扩展 选一处插入 3–5 条连续指令
→ ③ 口语化 改写 user 文本,插入短停顿
→ ④ 长停顿标记 在语义不完整处插入 [Nms]
→ ⑤ 子句切分 判断助手何时可接话
→ ⑥ 第三方事件 插入插话 / 招呼 / 回应 / 重述
按话题生成多轮对话。按 task_ratios 决定每轮是普通问答、话题转移还是打断。
打断怎么生成:让 user 挑一个"已经听到的信息点"作为切点,助手的话在那里被掐断。生成期就真的把 assistant 文本截成前缀,所以下游不需要猜。
一道精巧的校验(unheard_echo):如果用户的打断语里出现了只存在于切点之后的内容(≥3 个汉字),说明用户"听到了还没播出的部分",这条打断被拒绝。用户不可能引用他还没听到的话。
选一处干净轮次(排除被截断轮及其后一轮),插入 3–5 条指令。间隔由代码按哈希采样,与模型无关——保证同一份配置重跑得到同一批间隔。
每个指令块都强制同时包含"助手发声"和"助手不发声"两种情况,避免整批数据只有一种节奏。
LLM 改写每一轮 user 文本,插入填充词。输入是带候选轮次的 JSON 对象(eligible_turn_idx),输出是等长的 users 数组。
与 ③ 同一次调用完成。LLM 只在候选轮里考虑放标记,且放不放由它判断——没有真正卡词的地方就不放。
模型若在非候选轮写了标记(比如连续指令轮),代码会在存储前剥掉。
提示词层面已经写入了完整的落点判据(检索对象检查、左侧完整性检查、检索价值检查、固定搭配不得拆开、若干绝对禁止的落点)。但这些判据目前只靠提示词约束,代码里没有对应的校验环节:stage2_oralize.py 里定义的禁用左侧字符表、禁用左侧词表、犹豫词表目前未被调用,唯一生效的是"非候选轮剥掉标记"。因此本批仍能看到把介词与宾语拆开的落点(如 比[600ms]对抗路)以及紧跟省略号的落点(如 又是……[800ms]从哪儿)。
LLM 拿到去掉停顿标记的 user 文本,输出每轮的子句列表。校验很严:子句拼起来必须逐字符等于原文。
插入第三方插话、用户招呼、回应、重述。每个事件带一个 anchor 子串用于定位起声点,最终由后处理解析成字符下标。
六步流水线。前三步需要模型,后三步是纯逻辑与 DSP。
collect 脚本 → 片段清单 纯逻辑
tts 片段 → 音频(带缓存) 模型
align 音频 + 文本 → 逐字时间戳 模型
pause 长停顿按两段式合成与摆放 模型 + DSP
plan 片段 + 时长 → 时间轴 纯逻辑
render 时间轴 + 音频 → 双声道成品 DSP
为什么先规划后渲染:时间轴的规划是纯逻辑(输入是各段时长与字跨度,输出是绝对时间轴),可以脱离 TTS 单独测试。渲染只按既定时间轴贴音频。
为什么必须用字跨度而不是音频墙钟时长:TTS 输出的头尾静音不稳定(经验 50–300 ms),按"上一段音频长度"累加会逐段累积误差。所有切点、起声点都由对齐得到的字级跨度推导。
合成与对齐分离:每段合成后再对齐,断点以中间产物为准——已有音频和跨度就跳过。改间隔或停顿处理只需重跑规划与渲染。
一句话中途切开,左半句没有句末语调、右半句没有起音,中间再接一段零电平静音,听感是"信号掉了"而不是"人在想"。拆成两段独立合成后:
代价是每处标记多一次 TTS 调用(本批 55/468 段带标记)。可听停顿会比标记值长 200–300 ms,但这不影响时间戳——timeline.json 记的是每段绝对起止秒。
底噪怎么来(compose 时算一次,非频谱建模):
取材 同样本所有 slot == user 的波形(不用 backchannel / self_talk,不用 FA)
筛 hop 20ms 一帧算 RMS:
① |x| < 1e-6 丢掉(数字静音,含已插入的 N ms 零)
② RMS > -40 dBFS 丢掉(算作语音)
③ 余下取最小 RMS 为「底」,只留比它高不超过 8 dB 的 hop
拼接 合格 hop 首尾相接成模板,两端各 ≤5ms 淡化
音量不抬不压,保持原 RMS
填充 在带 [Nms] 的 wav 里找连续近零且 ≥80ms 的区间
np.tile 铺满,两端各 30ms 余弦淡入淡出
无合格 hop 或零区不够长 → 保持静音
结果:底噪 = 同一说话人合成音频里"既不是零、也不像语音"的最安静一档波形,直接循环。
| 项 | 值 |
|---|---|
| 采样率 | 16000 Hz,16-bit |
| 语速 user / assistant | 1.15 / 1.1 |
| 打断重叠 | 1000 ms |
| 第三方打断用户的重叠 | 250 ms |
| 长停顿 | 两段式独立合成;停顿区铺同说话人最安静 hop(20ms 平铺) |
| 底噪筛选 | 丢弃 ` |
| 底噪电平 | 实测中位 −89 dBFS(保持原 RMS,不做增益归一) |
| 填充触发 | 连续近零区间 ≥ 80 ms |
| 淡入淡出 | 模板两端 ≤5 ms;填充两端 30 ms |
| 可听停顿长度 | 标记值 N + 200 ~ 300 ms(时间戳不受影响) |
| 响度 | −23 LUFS(峰值 −1 dBTP) |
| 增益 / 远场 | 全 0 dB / 关闭 |
音色决定"谁在说",风格参考决定"怎么说"。短插话(附和、自语)喂"呢喃"参考,招呼第三方喂"呼唤"参考;主话语和打断不喂风格参考。这样同一音色下,附和与正经回答的听感不同。
| 术语 | 含义 |
|---|---|
| anchor | 文本里的定位子串,用于确定事件起声点;解析后变成字符下标 |
| char | 字符下标。注意 Stage 1.1 的轮次会写 char: null(表示"无字级切点"),它不是 0 |
assistant_full |
完整回复。被截断时 assistant 只是前缀,下游一律合成 full 再截 |
tail_gap_ms |
连续指令之间的静音长度,决定助手是否发声 |
user_clause |
语义子句列表,描述助手可以在哪些位置接话 |
interrupt_cue |
线索打断的前缀片段(稍等, / 停,),独立成片,标 reject |
| 两段式合成 | 长停顿处按标记把文本劈成两段分别合成,而不是切一条 wav 再插静音 |
| 停顿底噪 | 同说话人合成音频里最安静的一档 hop(20ms)平铺进停顿区,不是数字零 |
| 全双工 | 左右两轨可同时发声,模拟真实对话里的抢话与重叠 |