voidful commited on
Commit
7e7df2a
·
1 Parent(s): 334063e

Add fail-closed stable speaker inference

Browse files
Files changed (42) hide show
  1. README.md +77 -7
  2. app.py +426 -39
  3. bluemagpie/LICENSE.upstream +201 -0
  4. bluemagpie/UPSTREAM_RUNTIME.md +12 -0
  5. bluemagpie/__init__.py +29 -0
  6. bluemagpie/_vendor/__init__.py +6 -0
  7. bluemagpie/_vendor/voxcpm/LICENSE +201 -0
  8. bluemagpie/_vendor/voxcpm/PROVENANCE.md +35 -0
  9. bluemagpie/_vendor/voxcpm/__init__.py +17 -0
  10. bluemagpie/_vendor/voxcpm/model/__init__.py +14 -0
  11. bluemagpie/_vendor/voxcpm/model/utils.py +231 -0
  12. bluemagpie/_vendor/voxcpm/model/voxcpm2.py +1256 -0
  13. bluemagpie/_vendor/voxcpm/modules/__init__.py +0 -0
  14. bluemagpie/_vendor/voxcpm/modules/audiovae/__init__.py +2 -0
  15. bluemagpie/_vendor/voxcpm/modules/audiovae/audio_vae.py +377 -0
  16. bluemagpie/_vendor/voxcpm/modules/audiovae/audio_vae_v2.py +580 -0
  17. bluemagpie/_vendor/voxcpm/modules/layers/__init__.py +1 -0
  18. bluemagpie/_vendor/voxcpm/modules/layers/lora.py +130 -0
  19. bluemagpie/_vendor/voxcpm/modules/layers/scalar_quantization_layer.py +26 -0
  20. bluemagpie/_vendor/voxcpm/modules/locdit/__init__.py +3 -0
  21. bluemagpie/_vendor/voxcpm/modules/locdit/local_dit.py +114 -0
  22. bluemagpie/_vendor/voxcpm/modules/locdit/local_dit_v2.py +116 -0
  23. bluemagpie/_vendor/voxcpm/modules/locdit/unified_cfm.py +232 -0
  24. bluemagpie/_vendor/voxcpm/modules/locenc/__init__.py +1 -0
  25. bluemagpie/_vendor/voxcpm/modules/locenc/local_encoder.py +30 -0
  26. bluemagpie/_vendor/voxcpm/modules/minicpm4/__init__.py +3 -0
  27. bluemagpie/_vendor/voxcpm/modules/minicpm4/cache.py +47 -0
  28. bluemagpie/_vendor/voxcpm/modules/minicpm4/config.py +30 -0
  29. bluemagpie/_vendor/voxcpm/modules/minicpm4/model.py +431 -0
  30. bluemagpie/adapter.py +68 -0
  31. bluemagpie/centroid.py +67 -0
  32. bluemagpie/conditioning.py +37 -0
  33. bluemagpie/config.py +135 -0
  34. bluemagpie/loading.py +284 -0
  35. bluemagpie/model.py +717 -0
  36. bluemagpie/tslm.py +98 -0
  37. production.py +815 -1
  38. quality_runtime.py +1610 -0
  39. requirements.txt +15 -4
  40. tests/test_production.py +217 -0
  41. tests/test_quality_runtime.py +1450 -0
  42. tests/test_release_pins.py +197 -5
README.md CHANGED
@@ -20,7 +20,8 @@ models:
20
 
21
  ## 模式
22
 
23
- - **內建語者**:使用模型發佈內附的 speaker centroid
 
24
  - **參考音色**:從至少 3 秒的授權參考音檔抽取 ECAPA speaker embedding;參考內容不需要逐字稿。
25
  - **穩定長文**:使用和短句相同的 speaker embedding,切段後輸出一個完整音檔。
26
 
@@ -33,35 +34,104 @@ Demo 預設採用目前通過長文穩定性評估的推論設定:
33
 
34
  模型 snapshot 固定在 `aaf1a0878e37875382bb0e5c8a3a2ba43be67297`;
35
  speaker-reference 模式使用的 ECAPA encoder 固定在
36
- `0f99f2d0ebe89ac095bcc5903c4dd8f72b367286`TTS runtime package 亦固定 commit,
 
 
37
  避免 Space 重啟後在沒有程式版本變更的情況下改變模型或 speaker embedding 空間。
 
 
38
 
39
  | 設定 | 值 |
40
  |---|---:|
41
  | CFG | 2.0 |
42
  | NFE steps | 10 |
43
  | Target pace | 4.0 speech units/sec |
44
- | Generation safety pace | 5.2 CJK / 4.6 ASCII-mixed units/sec + 1 latent step |
 
45
  | Short-text guidance | minimum CFG 3.0 below 6 speech units |
46
  | Stop policy | 0.50 → 0.05 from 75% to 95% predicted progress, 1 hit |
47
  | Endpoint cue | append terminal punctuation for model input when missing, except very short text |
48
  | Hard stop | native-pace target steps, independent of playback pace |
49
- | Pace correction | pitch-preserving stretch after completion; onset split only at punctuation |
 
 
 
 
 
 
 
 
 
 
 
50
  | Maximum chunk | 80 chars |
51
  | Minimum chunk | 12 chars |
52
  | Crossfade / internal edge fade | 80 ms / 80 ms |
53
  | Chunk RMS adjustment | at most 4 dB |
54
  | Generated continuation context | 0 sec |
55
- | Retry / rerank | disabled |
 
56
 
57
- 每個請求會取得新的隨機 seed;同一請求內的所有長文切段會重用 seed。切段保留標點,
58
- 並依逗號、分號或句末標點插入不同長度的停頓。輸出最後會套保守的 RMS floor peak limit。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  自然 stop 在目前 checkpoint 上仍可能過快或錯過句尾。Demo 不再用播放目標語速決定生成長度;
61
  模型使用原生語速的安全上限,完成後才做保音高語速校正。缺少句末標點時只在模型輸入補上句號,
62
  少於 6 個 speech units 的短句會使用最低 CFG 3.0;stop threshold 只在預估進度 75% 後逐步降低,並於 95% 進度降至 0.05,以捕捉句末弱 stop 訊號而不影響前段內容。
63
  首段不會在無標點處硬切。進階參數可供研究比較,但 release
64
  profile 是 CFG 2.0、NFE 10、後處理語速 1.0。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  請只使用已取得授權的參考音檔。合成語音僅供研究與評估展示,正式使用前請人工檢視。
67
 
 
20
 
21
  ## 模式
22
 
23
+ - **內建語者**:使用模型發佈內附的 speaker centroid;預設為已重新稽核的
24
+ `female_voice`(介面中的內建語者 B)。
25
  - **參考音色**:從至少 3 秒的授權參考音檔抽取 ECAPA speaker embedding;參考內容不需要逐字稿。
26
  - **穩定長文**:使用和短句相同的 speaker embedding,切段後輸出一個完整音檔。
27
 
 
34
 
35
  模型 snapshot 固定在 `aaf1a0878e37875382bb0e5c8a3a2ba43be67297`;
36
  speaker-reference 模式使用的 ECAPA encoder 固定在
37
+ `0f99f2d0ebe89ac095bcc5903c4dd8f72b367286`TTS runtime
38
+ `ce384c8cc54efea1aaba7b9f1d7ded6c1c99aa9a` 的已驗證原始碼隨 Space 保存,
39
+ Barbet 另固定在 `6fcd7ce4aa37f2250a3242995bef0fbc3b026ba8`,
40
  避免 Space 重啟後在沒有程式版本變更的情況下改變模型或 speaker embedding 空間。
41
+ 候選語意驗證固定使用 Whisper large-v3-turbo revision
42
+ `41f01f3fe87f28c78e2fbf8b568835947dd65ed9`。
43
 
44
  | 設定 | 值 |
45
  |---|---:|
46
  | CFG | 2.0 |
47
  | NFE steps | 10 |
48
  | Target pace | 4.0 speech units/sec |
49
+ | Candidate 0 generation policy | base: 5.2 CJK / 4.6 ASCII-mixed units/sec + 1 latent step |
50
+ | Candidate 1–9 generation policy | safe duration: 4.6 CJK / 4.0 ASCII-mixed units/sec + 1 latent step |
51
  | Short-text guidance | minimum CFG 3.0 below 6 speech units |
52
  | Stop policy | 0.50 → 0.05 from 75% to 95% predicted progress, 1 hit |
53
  | Endpoint cue | append terminal punctuation for model input when missing, except very short text |
54
  | Hard stop | native-pace target steps, independent of playback pace |
55
+ | Pace correction | whole completed chunk, pitch-preserving; active-region stretcher disabled |
56
+ | Pace / speaker eligibility duration | union of active 25 ms RMS frames at 10 ms hop; internal pauses excluded |
57
+ | Semantic verification | whole / first 6 / last 6 units, no lexical tail; fail closed |
58
+ | Speaker verification | frozen request centroid + begin/end directional drift |
59
+ | Early accept speaker preference | similarity ≥ 0.25 and directional drop ≤ 0.05 for gated chunks |
60
+ | Final hard speaker gate | similarity ≥ 0.10 and directional drop ≤ 0.10 |
61
+ | Long speaker evidence | one ECAPA batch over up to four 3-second windows + begin/end |
62
+ | Candidate cascade | same-seed whole trajectory first; then 1→5→10 sequence fallback within budget |
63
+ | Whole-trajectory qualification | assemble with production RMS/fade/pause/crossfade/speed, then whole-output gate |
64
+ | Sequence fallback | up to 3 distinct k-best paths from gate-passed chunks; each exact-assembled and whole-gated |
65
+ | Final output gate | re-verify joined/faded/RMS-matched/speed-adjusted whole waveform; fail closed |
66
+ | Runtime budget | at most 20 generated TTS chunks per request; at most 10 candidates |
67
  | Maximum chunk | 80 chars |
68
  | Minimum chunk | 12 chars |
69
  | Crossfade / internal edge fade | 80 ms / 80 ms |
70
  | Chunk RMS adjustment | at most 4 dB |
71
  | Generated continuation context | 0 sec |
72
+ | Generated-audio retry | disabled |
73
+ | Quality selection | enabled; no unverified fallback |
74
 
75
+ 每個請求會取得新的隨機 root seed;每個候選 trajectory 的所有長文切段會重用同一 seed。
76
+ `candidate 0` 使base policy(5.2 CJK / 4.6 ASCII),`candidate 1–9` 使用實證回收用的
77
+ safe-duration policy(4.6 CJK / 4.0 ASCII);兩者都只改 native-duration endpoint estimate,
78
+ margin 固定 +1 latent step、`min_len` 固定為 2,不會為了播放目標語速硬撐 generation loop。
79
+ 選中完整 trajectory 或 sequence-DP 混合路徑後,log 會記錄每個 chunk 的 candidate、seed 與 policy。
80
+ 內部 hosted evaluator 可注入 `[0, 2^31)` 的固定 root seed 以重現結果;UI 不暴露這個參數,
81
+ 一般請求仍只在未注入 seed 時使用系統亂數。
82
+ 完整 same-seed trajectory 永遠優先;只有一個 stage 沒有完整 trajectory 時,才允許 sequence DP
83
+ 從該 stage 已通過 hard gate 的 chunks 混合 seed。切段保留標點,並依逗號、分號或句末標點
84
+ 插入不同長度的停頓。輸出最後會套用保守的 RMS floor 與 peak limit。
85
+ 完成 RMS matching、edge fade、pause、crossfade 與使用者 speed 後,服務會再對最終整段 waveform
86
+ 執行一次 normalized target 的 prefix/whole/suffix/tail、pace 與 speaker anchor/boundary gate;這個
87
+ post-join gate 失敗時不會回傳先前已通過的 chunk 音訊。
88
+ 此外,same-seed trajectory 只有在每個 local chunk 都通過後,才會先用同一個
89
+ `_assemble_trajectory_audio` 組成實際播放版本並跑整段 semantic/pace/speaker gate;joined gate
90
+ 失敗會取消該 whole trajectory 的資格,但保留已通過的 local evidence 供 sequence DP 使用。
91
+ 這個額外 whole-ASR 只對 local 全數通過的候選執行,DP 選定後仍會保留上述最終 post-join gate。
92
+ 若整個 candidate budget 都沒有 joined-safe whole trajectory,多 chunk request 才會進入 bounded
93
+ k-best DP。Local score 加上相鄰 ECAPA/RMS transition cost 會產生最多 3 條不同完整路徑,依
94
+ `(total cost, candidate-index path)` 穩定排序;每條都用同一個 production assembler 和整段
95
+ semantic/pace/speaker gate 驗證,第一條通過才回傳。三條都失敗、callback 異常或沒有完整
96
+ finite path 時一律 fail closed。這不增加 TTS generation 數量,也不套用於單 chunk request。
97
+ 被拒的 joined/final log 只記 normalized CER、prefix/suffix CER、tail units 與 reasons,不記逐字
98
+ transcript。
99
+
100
+ 日期、24 小時制時間、百分比、常見單位與大寫 acronym/model code 會先轉成保守的
101
+ zh-TW spoken form,例如 `2026/07/16`、`15:30`、`12.5%` 與 `10 km`。ASR 會先統一
102
+ 繁簡字形再評分,避免把正確的台灣華語輸出誤判為內容錯誤。
103
+ ASR 評分另只將實證可互換的同音代詞 `她/它/牠/祂` 視為 `他`、`妳` 視為 `你`;
104
+ 這項 canonicalization 不會修改送入模型的文字,也不包含 `的/地/得` 或 `在/再`。
105
 
106
  自然 stop 在目前 checkpoint 上仍可能過快或錯過句尾。Demo 不再用播放目標語速決定生成長度;
107
  模型使用原生語速的安全上限,完成後才做保音高語速校正。缺少句末標點時只在模型輸入補上句號,
108
  少於 6 個 speech units 的短句會使用最低 CFG 3.0;stop threshold 只在預估進度 75% 後逐步降低,並於 95% 進度降至 0.05,以捕捉句末弱 stop 訊號而不影響前段內容。
109
  首段不會在無標點處硬切。進階參數可供研究比較,但 release
110
  profile 是 CFG 2.0、NFE 10、後處理語速 1.0。
111
+ Online pace gate 與 1.5 秒 speaker eligibility 使用和獨立 hosted evaluator 相同的 active-frame
112
+ interval union(25 ms frame、10 ms hop、peak -35 dB、absolute RMS floor 1e-4),因此句內或插入的
113
+ pause 不會稀釋 CPS。逐 active interval 的 stretcher 曾在固定 root ablation 造成長文 availability
114
+ 回退,目前 production profile 不啟用;這個 union duration 只用於正確量測與 fail-closed gate。
115
+
116
+ 第一個候選同時通過 local 與 joined whole gate,且符合 early-accept speaker preference 時才會
117
+ 立即回傳;若 generated-chunk budget
118
+ 允許,短 request 才會依序擴展到 5、10 個候選。每個 stage 先找完整 same-seed trajectory;
119
+ 整個 budget 完全找不到 joined-safe whole trajectory 時,才用逐 chunk semantic/pace/speaker
120
+ hard gate 的 local score,以及相鄰 chunks 的 ECAPA cosine 與 active-RMS dB 差做 bounded
121
+ k-best dynamic programming。多 chunk request 的候選上限是
122
+ `min(10, floor(20 / chunk count))`:1/2 chunks 可到 K10、3 chunks K6、4 chunks K5、
123
+ 5 chunks K4、6 chunks K3,因此不會無限制地把 K10 套到 360 字長文。
124
+ 非 final stage 的 speaker-gated chunks 只有在 similarity 至少 0.25、directional boundary drop
125
+ 不超過 0.05 時才能 early accept。只通過 0.10/0.10 hard gate 的 marginal candidate 會保留並
126
+ 繼續下一個可用 stage;到了 request budget 的 final stage 仍可作為 verified fallback,避免降低
127
+ availability。未套 speaker hard gate 的極短句不受 preferred threshold 阻擋。
128
+ 若所有候選仍有漏字、多說字、句尾截斷、語速過快或可量測的音色漂移,服務會拒絕輸出,
129
+ 不會回傳任意 fallback。極短音檔的分段 ECAPA 不可靠,因此只採嚴格語意 gate,並在
130
+ release audit 中獨立標記為 speaker-unverified;這不代表模型的短句音色已被根治。
131
+
132
+ 目前部署路徑不使用 reference-style distribution score;sequence DP 已使用 speaker/RMS
133
+ transition,但尚未啟用 normalized median-F0 transition。Style 與 F0 helper 仍只供離線實驗,
134
+ 不能把它們列為目前 Space 已提供的穩定性保證。
135
 
136
  請只使用已取得授權的參考音檔。合成語音僅供研究與評估展示,正式使用前請人工檢視。
137
 
app.py CHANGED
@@ -28,6 +28,7 @@ from production import (
28
  finish_audio,
29
  join_audio_chunks,
30
  match_chunk_rms,
 
31
  normalize_tts_text,
32
  punctuation_pause_seconds,
33
  select_generation_cps,
@@ -36,6 +37,29 @@ from production import (
36
  split_text_for_tts,
37
  target_pace_speed,
38
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
  try:
@@ -57,8 +81,6 @@ ECAPA_REVISION = "0f99f2d0ebe89ac095bcc5903c4dd8f72b367286"
57
  DEFAULT_CFG = 2.0
58
  DEFAULT_STEPS = 10
59
  TARGET_CPS = 4.0
60
- GENERATION_CPS = 5.2
61
- ASCII_GENERATION_CPS = 4.6
62
  MIN_ENDPOINT_CUE_UNITS = 6
63
  SHORT_TEXT_CFG_MIN = 3.0
64
  SHORT_TEXT_CFG_UNITS = 6
@@ -73,13 +95,27 @@ STOP_LATE_THRESHOLD = 0.05
73
  STOP_LATE_START_RATIO = 0.75
74
  STOP_LATE_FULL_RATIO = 0.95
75
  STOP_CONSECUTIVE = 1
76
- HARD_STOP_MARGIN_STEPS = 1
77
  MIN_PACE_SPEED = 0.80
78
  MAX_TEXT_CHARS = 360
 
 
 
 
 
 
 
 
 
 
 
79
 
80
 
81
  print(f"[BlueMagpie] downloading model from {REPO_ID}@{MODEL_REVISION} ...")
82
  MODEL_DIR = snapshot_download(REPO_ID, revision=MODEL_REVISION)
 
 
 
 
83
  tokenizer = PreTrainedTokenizerFast(tokenizer_file=os.path.join(MODEL_DIR, "tokenizer.json"))
84
  print(f"[BlueMagpie] loading model on device={DEVICE} ...")
85
  model = BlueMagpieModel.from_local(MODEL_DIR, tokenizer=tokenizer, training=False, device=DEVICE)
@@ -110,6 +146,8 @@ def _load_speakers() -> tuple[dict[str, torch.Tensor], str]:
110
  if isinstance(METADATA.get("recommended_generation_defaults"), dict)
111
  else None
112
  )
 
 
113
  default_index = speaker_ids.index(requested_id) if requested_id in speaker_ids else 0
114
  return labels, f"內建語者 {chr(65 + default_index)}"
115
 
@@ -157,8 +195,11 @@ def _get_ecapa_encoder():
157
 
158
  cache_dir = os.path.join(os.environ.get("HF_HOME", "/tmp"), "speechbrain", "ecapa")
159
  _ECAPA_ENCODER = EncoderClassifier.from_hparams(
160
- source=ECAPA_REPO_ID,
161
- revision=ECAPA_REVISION,
 
 
 
162
  savedir=cache_dir,
163
  run_opts={"device": "cpu"},
164
  )
@@ -179,17 +220,18 @@ def _generate_chunk(
179
  cfg: float,
180
  steps: int,
181
  request_seed: int,
 
182
  ) -> np.ndarray:
183
  generation_cps = select_generation_cps(
184
  text,
185
- cjk_cps=GENERATION_CPS,
186
- ascii_cps=ASCII_GENERATION_CPS,
187
  )
188
  model_text, expected_steps, hard_stop_steps = endpoint_generation_plan(
189
  text,
190
  generation_cps=generation_cps,
191
  step_seconds=STEP_SECONDS,
192
- margin_steps=HARD_STOP_MARGIN_STEPS,
193
  add_terminal_punctuation=count_speech_units(text) >= MIN_ENDPOINT_CUE_UNITS,
194
  )
195
  # Do not hold generation open to enforce pace. The model can finish the
@@ -237,6 +279,11 @@ def _generate_chunk(
237
  f"generated_steps={_STOP_CONTROLLER.last_generated_steps} "
238
  f"reason={_STOP_CONTROLLER.last_stop_reason} cfg={generation_cfg:.2f}"
239
  )
 
 
 
 
 
240
  audio = audio.detach().float().cpu().numpy().reshape(-1)
241
  pace_speed = target_pace_speed(
242
  audio.size,
@@ -248,6 +295,273 @@ def _generate_chunk(
248
  return _apply_speed(audio, pace_speed)
249
 
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  def _synthesize(
252
  text: str,
253
  centroid: torch.Tensor,
@@ -255,12 +569,15 @@ def _synthesize(
255
  cfg: float,
256
  steps: int,
257
  speed: float,
 
258
  ) -> tuple[int, np.ndarray]:
259
- text = normalize_tts_text(text)
260
  if not text:
261
  raise gr.Error("請先輸入要合成的文字。")
262
  if len(text) > MAX_TEXT_CHARS:
263
  raise gr.Error(f"單次最多 {MAX_TEXT_CHARS} 個字元,請分段合成。")
 
 
264
 
265
  chunks = split_text_for_tts(text, max_chars=CHUNK_CHARS, min_chunk_chars=MIN_CHUNK_CHARS)
266
  if chunks:
@@ -270,33 +587,96 @@ def _synthesize(
270
  min_chunk_chars=MIN_CHUNK_CHARS,
271
  )
272
  chunks = onset_chunks + chunks[1:]
273
- request_seed = secrets.randbelow(2**31)
274
- audio_chunks: list[np.ndarray] = []
275
- pauses: list[int] = []
276
- with _GENERATION_LOCK:
277
- for index, chunk in enumerate(chunks):
278
- audio = _generate_chunk(
279
- chunk,
280
- centroid,
281
- cfg=cfg,
282
- steps=steps,
283
- request_seed=request_seed,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  )
285
- if audio_chunks:
286
- audio = match_chunk_rms(audio_chunks[0], audio, max_adjust_db=CHUNK_RMS_MATCH_DB)
287
- audio_chunks.append(audio)
288
- if index + 1 < len(chunks):
289
- pauses.append(int(round(punctuation_pause_seconds(chunk) * SR)))
290
-
291
- audio_chunks = fade_internal_edges(audio_chunks, SR, fade_ms=CHUNK_EDGE_FADE_MS)
292
- waveform = join_audio_chunks(
293
- audio_chunks,
294
- pauses,
295
- crossfade_samples=int(round(CROSSFADE_MS * SR / 1000.0)),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  )
297
- waveform = apply_loudness_floor(waveform, min_rms=0.07, peak_limit=0.95, max_gain=3.0)
298
- waveform = _apply_speed(waveform, speed)
299
- return SR, finish_audio(waveform, SR)
300
 
301
 
302
  @gpu
@@ -380,12 +760,19 @@ HEADER = f"""
380
 
381
  台灣華語與中英混合文字轉語音。模型版本:`{CHECKPOINT}`。
382
 
383
- 執行環境固定於已驗證的 model revision `{MODEL_REVISION[:8]}`ECAPA revision
384
- `{ECAPA_REVISION[:8]}`,避免服務重啟時無聲變更權重或 speaker embedding 空間。
 
385
 
386
  目前預設採用穩定推論設定:`CFG 2.0`(極短句最低 `3.0`)、`NFE 10`、目標語速 `4.0 字/秒`、
387
- 生成上限依模型原生語速計算、生成完成後校正至目標語速、補齊句末提示、尾端 weak-stop 保護、
388
- 只在自然標點切開首段、每 80 字切段且不使用 retry rerank。
 
 
 
 
 
 
389
  """
390
 
391
 
@@ -396,7 +783,7 @@ with gr.Blocks(title="BlueMagpie-TTS Demo", theme=gr.themes.Soft()) as demo:
396
  with gr.Row():
397
  cfg_input = gr.Slider(1.0, 4.0, value=DEFAULT_CFG, step=0.1, label="CFG")
398
  steps_input = gr.Slider(4, 20, value=DEFAULT_STEPS, step=1, label="NFE steps")
399
- speed_input = gr.Slider(0.85, 1.1, value=1.0, step=0.05, label="後處理語速")
400
 
401
  with gr.Tab("內建語者"):
402
  with gr.Row():
 
28
  finish_audio,
29
  join_audio_chunks,
30
  match_chunk_rms,
31
+ normalize_spoken_forms,
32
  normalize_tts_text,
33
  punctuation_pause_seconds,
34
  select_generation_cps,
 
37
  split_text_for_tts,
38
  target_pace_speed,
39
  )
40
+ from quality_runtime import (
41
+ BASE_GENERATION_POLICY,
42
+ SAFE_DURATION_GENERATION_POLICY,
43
+ WHISPER_MODEL_ID,
44
+ WHISPER_REVISION,
45
+ CandidateObservation,
46
+ ChunkCandidateArtifact,
47
+ FinalOutputRejectedError,
48
+ GenerationPolicy,
49
+ NoQualifiedCandidateError,
50
+ active_voiced_duration_seconds,
51
+ active_audio_rms_db,
52
+ candidate_limit_for_chunk_budget,
53
+ generation_policy_for_candidate_offset,
54
+ prepare_candidate_audio,
55
+ qualify_trajectory_with_joined_output,
56
+ require_verified_final_output,
57
+ resolve_request_seed,
58
+ run_adaptive_cascade,
59
+ speaker_evidence_from_audio,
60
+ transcribe_whisper,
61
+ verify_trajectory,
62
+ )
63
 
64
 
65
  try:
 
81
  DEFAULT_CFG = 2.0
82
  DEFAULT_STEPS = 10
83
  TARGET_CPS = 4.0
 
 
84
  MIN_ENDPOINT_CUE_UNITS = 6
85
  SHORT_TEXT_CFG_MIN = 3.0
86
  SHORT_TEXT_CFG_UNITS = 6
 
95
  STOP_LATE_START_RATIO = 0.75
96
  STOP_LATE_FULL_RATIO = 0.95
97
  STOP_CONSECUTIVE = 1
 
98
  MIN_PACE_SPEED = 0.80
99
  MAX_TEXT_CHARS = 360
100
+ QUALITY_MAX_CANDIDATES = 10
101
+ QUALITY_MAX_GENERATED_CHUNKS = 20
102
+ QUALITY_FINAL_ASR_MAX_NEW_TOKENS = 440
103
+ QUALITY_MAX_CER = 0.20
104
+ QUALITY_MAX_PACE_CPS = 4.30
105
+ QUALITY_PREFIX_SUFFIX_UNITS = 6
106
+ QUALITY_MIN_SPEAKER_SIMILARITY = 0.10
107
+ QUALITY_MAX_BOUNDARY_SPEAKER_DROP = 0.10
108
+ QUALITY_PREFERRED_MIN_SPEAKER_SIMILARITY = 0.25
109
+ QUALITY_PREFERRED_MAX_BOUNDARY_SPEAKER_DROP = 0.05
110
+ SHORT_AUDIO_SPEAKER_GATE_SECONDS = 1.50
111
 
112
 
113
  print(f"[BlueMagpie] downloading model from {REPO_ID}@{MODEL_REVISION} ...")
114
  MODEL_DIR = snapshot_download(REPO_ID, revision=MODEL_REVISION)
115
+ print(f"[BlueMagpie] caching speaker encoder from {ECAPA_REPO_ID}@{ECAPA_REVISION} ...")
116
+ ECAPA_DIR = snapshot_download(ECAPA_REPO_ID, revision=ECAPA_REVISION)
117
+ print(f"[BlueMagpie] caching quality ASR from {WHISPER_MODEL_ID}@{WHISPER_REVISION} ...")
118
+ ASR_DIR = snapshot_download(WHISPER_MODEL_ID, revision=WHISPER_REVISION)
119
  tokenizer = PreTrainedTokenizerFast(tokenizer_file=os.path.join(MODEL_DIR, "tokenizer.json"))
120
  print(f"[BlueMagpie] loading model on device={DEVICE} ...")
121
  model = BlueMagpieModel.from_local(MODEL_DIR, tokenizer=tokenizer, training=False, device=DEVICE)
 
146
  if isinstance(METADATA.get("recommended_generation_defaults"), dict)
147
  else None
148
  )
149
+ if requested_id not in speaker_ids and "female_voice" in speaker_ids:
150
+ requested_id = "female_voice"
151
  default_index = speaker_ids.index(requested_id) if requested_id in speaker_ids else 0
152
  return labels, f"內建語者 {chr(65 + default_index)}"
153
 
 
195
 
196
  cache_dir = os.path.join(os.environ.get("HF_HOME", "/tmp"), "speechbrain", "ecapa")
197
  _ECAPA_ENCODER = EncoderClassifier.from_hparams(
198
+ source=ECAPA_DIR,
199
+ # The upstream hyperparams otherwise points back to the repo
200
+ # and SpeechBrain 1.0.3 uses a removed Hub keyword. Keep every
201
+ # weight fetch inside the already pinned local snapshot.
202
+ overrides={"pretrained_path": ECAPA_DIR},
203
  savedir=cache_dir,
204
  run_opts={"device": "cpu"},
205
  )
 
220
  cfg: float,
221
  steps: int,
222
  request_seed: int,
223
+ policy: GenerationPolicy,
224
  ) -> np.ndarray:
225
  generation_cps = select_generation_cps(
226
  text,
227
+ cjk_cps=policy.cjk_cps,
228
+ ascii_cps=policy.ascii_cps,
229
  )
230
  model_text, expected_steps, hard_stop_steps = endpoint_generation_plan(
231
  text,
232
  generation_cps=generation_cps,
233
  step_seconds=STEP_SECONDS,
234
+ margin_steps=policy.hard_stop_margin_steps,
235
  add_terminal_punctuation=count_speech_units(text) >= MIN_ENDPOINT_CUE_UNITS,
236
  )
237
  # Do not hold generation open to enforce pace. The model can finish the
 
279
  f"generated_steps={_STOP_CONTROLLER.last_generated_steps} "
280
  f"reason={_STOP_CONTROLLER.last_stop_reason} cfg={generation_cfg:.2f}"
281
  )
282
+ print(
283
+ "[BlueMagpie] generation policy "
284
+ f"name={policy.name} seed={request_seed} generation_cps={generation_cps:.2f} "
285
+ f"expected_steps={expected_steps} hard_stop_steps={hard_stop_steps} min_len={min_len}"
286
+ )
287
  audio = audio.detach().float().cpu().numpy().reshape(-1)
288
  pace_speed = target_pace_speed(
289
  audio.size,
 
295
  return _apply_speed(audio, pace_speed)
296
 
297
 
298
+ def _speaker_anchor_array(centroid: torch.Tensor) -> np.ndarray:
299
+ anchor = torch.as_tensor(centroid).detach().float().cpu().numpy().reshape(-1)
300
+ if anchor.size == 0 or not np.isfinite(anchor).all():
301
+ raise ValueError("speaker anchor is invalid")
302
+ norm = float(np.linalg.norm(anchor))
303
+ if not np.isfinite(norm) or norm <= 1.0e-8:
304
+ raise ValueError("speaker anchor has zero norm")
305
+ return np.asarray(anchor / norm, dtype=np.float32)
306
+
307
+
308
+ def _generate_trajectory(
309
+ chunks: tuple[str, ...],
310
+ centroid: torch.Tensor,
311
+ *,
312
+ cfg: float,
313
+ steps: int,
314
+ request_seed: int,
315
+ policy: GenerationPolicy,
316
+ ) -> tuple[np.ndarray, ...]:
317
+ return tuple(
318
+ _generate_chunk(
319
+ chunk,
320
+ centroid,
321
+ cfg=cfg,
322
+ steps=steps,
323
+ request_seed=request_seed,
324
+ policy=policy,
325
+ )
326
+ for chunk in chunks
327
+ )
328
+
329
+
330
+ def _verify_trajectory_audio(
331
+ trajectory: tuple[np.ndarray, ...],
332
+ chunks: tuple[str, ...],
333
+ anchor: np.ndarray,
334
+ playback_speed: float,
335
+ asr_max_new_tokens: int = 128,
336
+ ):
337
+ if len(trajectory) != len(chunks):
338
+ return verify_trajectory(())
339
+ encoder = None
340
+ observations: list[CandidateObservation] = []
341
+ artifacts: list[ChunkCandidateArtifact] = []
342
+ for chunk, audio in zip(chunks, trajectory, strict=True):
343
+ prepared = prepare_candidate_audio(
344
+ audio,
345
+ SR,
346
+ transcriber=lambda waveform, sample_rate: transcribe_whisper(
347
+ waveform,
348
+ sample_rate,
349
+ max_new_tokens=asr_max_new_tokens,
350
+ ),
351
+ )
352
+ if prepared is None:
353
+ observations.append(
354
+ CandidateObservation(
355
+ target_text=chunk,
356
+ transcript_text="",
357
+ audio_duration_seconds=0.0,
358
+ pace_cps=None,
359
+ )
360
+ )
361
+ artifacts.append(ChunkCandidateArtifact())
362
+ continue
363
+ waveform = prepared.waveform
364
+ try:
365
+ duration = active_voiced_duration_seconds(waveform, SR)
366
+ except ValueError:
367
+ duration = 0.0
368
+ transcript = prepared.transcript_text
369
+ speaker_similarity = None
370
+ begin_similarity = None
371
+ end_similarity = None
372
+ speaker_embedding = None
373
+ rms_db = None
374
+ if duration >= SHORT_AUDIO_SPEAKER_GATE_SECONDS:
375
+ try:
376
+ if encoder is None:
377
+ encoder = _get_ecapa_encoder()
378
+ evidence = speaker_evidence_from_audio(
379
+ waveform,
380
+ SR,
381
+ encoder,
382
+ anchor,
383
+ device="cpu",
384
+ )
385
+ duration = evidence.active_duration_seconds
386
+ speaker_similarity = evidence.similarity
387
+ begin_similarity = evidence.begin_similarity
388
+ end_similarity = evidence.end_similarity
389
+ speaker_embedding = evidence.speaker_embedding
390
+ rms_db = evidence.active_rms_db
391
+ except ValueError:
392
+ # A malformed/empty speaker measurement remains missing and is
393
+ # rejected by the fail-closed gate for non-short candidates.
394
+ pass
395
+ if rms_db is None:
396
+ try:
397
+ rms_db = active_audio_rms_db(waveform)
398
+ except ValueError:
399
+ pass
400
+ observations.append(
401
+ CandidateObservation(
402
+ target_text=chunk,
403
+ transcript_text=transcript,
404
+ audio_duration_seconds=duration,
405
+ speaker_similarity=speaker_similarity,
406
+ begin_speaker_similarity=begin_similarity,
407
+ end_speaker_similarity=end_similarity,
408
+ pace_cps=(
409
+ count_speech_units(chunk) / duration * float(playback_speed)
410
+ if duration > 0.0
411
+ else None
412
+ ),
413
+ )
414
+ )
415
+ artifacts.append(
416
+ ChunkCandidateArtifact(
417
+ speaker_embedding=speaker_embedding,
418
+ rms_db=rms_db,
419
+ )
420
+ )
421
+ return verify_trajectory(
422
+ observations,
423
+ chunk_artifacts=artifacts,
424
+ short_text_units=6,
425
+ short_text_max_cer=0.0,
426
+ max_cer=QUALITY_MAX_CER,
427
+ prefix_units=QUALITY_PREFIX_SUFFIX_UNITS,
428
+ suffix_units=QUALITY_PREFIX_SUFFIX_UNITS,
429
+ max_prefix_cer=0.0,
430
+ max_suffix_cer=0.0,
431
+ max_extra_tail_units=0,
432
+ short_audio_seconds=SHORT_AUDIO_SPEAKER_GATE_SECONDS,
433
+ min_speaker_similarity=QUALITY_MIN_SPEAKER_SIMILARITY,
434
+ max_boundary_speaker_drop=QUALITY_MAX_BOUNDARY_SPEAKER_DROP,
435
+ max_pace_cps=QUALITY_MAX_PACE_CPS,
436
+ )
437
+
438
+
439
+ def _assemble_trajectory_audio(
440
+ trajectory: tuple[np.ndarray, ...],
441
+ chunks: tuple[str, ...],
442
+ playback_speed: float,
443
+ ) -> np.ndarray:
444
+ """Assemble chunks exactly as they will be returned to the listener."""
445
+
446
+ if not trajectory or len(trajectory) != len(chunks):
447
+ raise ValueError("trajectory and text chunks must be non-empty and aligned")
448
+ audio_chunks = [np.asarray(audio, dtype=np.float32).copy() for audio in trajectory]
449
+ pauses: list[int] = []
450
+ for index, chunk in enumerate(chunks):
451
+ if index > 0:
452
+ audio_chunks[index] = match_chunk_rms(
453
+ audio_chunks[0],
454
+ audio_chunks[index],
455
+ max_adjust_db=CHUNK_RMS_MATCH_DB,
456
+ )
457
+ if index + 1 < len(chunks):
458
+ pauses.append(int(round(punctuation_pause_seconds(chunk) * SR)))
459
+
460
+ audio_chunks = fade_internal_edges(audio_chunks, SR, fade_ms=CHUNK_EDGE_FADE_MS)
461
+ waveform = join_audio_chunks(
462
+ audio_chunks,
463
+ pauses,
464
+ crossfade_samples=int(round(CROSSFADE_MS * SR / 1000.0)),
465
+ )
466
+ waveform = apply_loudness_floor(
467
+ waveform,
468
+ min_rms=0.07,
469
+ peak_limit=0.95,
470
+ max_gain=3.0,
471
+ )
472
+ waveform = _apply_speed(waveform, playback_speed)
473
+ return finish_audio(waveform, SR)
474
+
475
+
476
+ def _verification_metric_log_fields(verification) -> str:
477
+ """Format normalized semantic metrics without logging transcript content."""
478
+
479
+ if len(verification.candidate_results) != 1:
480
+ return (
481
+ "cer=nan prefix_cer=nan suffix_cer=nan tail_units=nan "
482
+ f"reasons={verification.rejection_reasons}"
483
+ )
484
+ comparison = verification.candidate_results[0].comparison
485
+ return (
486
+ f"cer={comparison.cer:.6f} prefix_cer={comparison.prefix_cer:.6f} "
487
+ f"suffix_cer={comparison.suffix_cer:.6f} "
488
+ f"tail_units={comparison.extra_tail_units} "
489
+ f"reasons={verification.rejection_reasons}"
490
+ )
491
+
492
+
493
+ def _qualify_candidate_trajectory_audio(
494
+ trajectory: tuple[np.ndarray, ...],
495
+ chunks: tuple[str, ...],
496
+ whole_target_text: str,
497
+ anchor: np.ndarray,
498
+ playback_speed: float,
499
+ *,
500
+ candidate_seed: int,
501
+ ):
502
+ """Run whole-output qualification only after every local chunk passes."""
503
+
504
+ local_verification = _verify_trajectory_audio(
505
+ trajectory,
506
+ chunks,
507
+ anchor,
508
+ playback_speed,
509
+ )
510
+ if not local_verification.passed:
511
+ return local_verification
512
+
513
+ waveform = _assemble_trajectory_audio(trajectory, chunks, playback_speed)
514
+ joined_verification = _verify_trajectory_audio(
515
+ (waveform,),
516
+ (whole_target_text,),
517
+ anchor,
518
+ 1.0,
519
+ QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
520
+ )
521
+ qualified = qualify_trajectory_with_joined_output(
522
+ local_verification,
523
+ joined_verification,
524
+ )
525
+ if not qualified.passed:
526
+ print(
527
+ "[BlueMagpie] candidate joined output rejected "
528
+ f"seed={candidate_seed} "
529
+ f"{_verification_metric_log_fields(joined_verification)}"
530
+ )
531
+ return qualified
532
+
533
+
534
+ def _verify_sequence_trajectory_audio(
535
+ sequence_result,
536
+ chunks: tuple[str, ...],
537
+ whole_target_text: str,
538
+ anchor: np.ndarray,
539
+ playback_speed: float,
540
+ ):
541
+ """Verify one ranked DP path after exact production assembly."""
542
+
543
+ waveform = _assemble_trajectory_audio(
544
+ sequence_result.trajectory,
545
+ chunks,
546
+ playback_speed,
547
+ )
548
+ verification = _verify_trajectory_audio(
549
+ (waveform,),
550
+ (whole_target_text,),
551
+ anchor,
552
+ 1.0,
553
+ QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
554
+ )
555
+ status = "verified" if verification.passed else "rejected"
556
+ print(
557
+ f"[BlueMagpie] sequence path {status} "
558
+ f"rank={sequence_result.sequence_path_rank} "
559
+ f"chunk_candidates={sequence_result.chunk_candidate_indices} "
560
+ f"{_verification_metric_log_fields(verification)}"
561
+ )
562
+ return verification
563
+
564
+
565
  def _synthesize(
566
  text: str,
567
  centroid: torch.Tensor,
 
569
  cfg: float,
570
  steps: int,
571
  speed: float,
572
+ request_seed: int | None = None,
573
  ) -> tuple[int, np.ndarray]:
574
+ text = normalize_spoken_forms(text, locale="zh-TW")
575
  if not text:
576
  raise gr.Error("請先輸入要合成的文字。")
577
  if len(text) > MAX_TEXT_CHARS:
578
  raise gr.Error(f"單次最多 {MAX_TEXT_CHARS} 個字元,請分段合成。")
579
+ if not np.isfinite(float(speed)) or not 0.85 <= float(speed) <= 1.05:
580
+ raise gr.Error("後處理語速必須介於 0.85 與 1.05。")
581
 
582
  chunks = split_text_for_tts(text, max_chars=CHUNK_CHARS, min_chunk_chars=MIN_CHUNK_CHARS)
583
  if chunks:
 
587
  min_chunk_chars=MIN_CHUNK_CHARS,
588
  )
589
  chunks = onset_chunks + chunks[1:]
590
+ request_seed = resolve_request_seed(request_seed, secrets.randbelow)
591
+ max_candidates = candidate_limit_for_chunk_budget(
592
+ len(chunks),
593
+ max_candidates=QUALITY_MAX_CANDIDATES,
594
+ max_generated_chunks=QUALITY_MAX_GENERATED_CHUNKS,
595
+ )
596
+ anchor = _speaker_anchor_array(centroid)
597
+ try:
598
+ with _GENERATION_LOCK:
599
+ cascade = run_adaptive_cascade(
600
+ chunks,
601
+ request_seed,
602
+ lambda candidate_chunks, seed: _generate_trajectory(
603
+ candidate_chunks,
604
+ centroid,
605
+ cfg=cfg,
606
+ steps=steps,
607
+ request_seed=seed,
608
+ policy=generation_policy_for_candidate_offset(seed - request_seed),
609
+ ),
610
+ lambda trajectory, candidate_chunks, seed: _qualify_candidate_trajectory_audio(
611
+ trajectory,
612
+ candidate_chunks,
613
+ text,
614
+ anchor,
615
+ speed,
616
+ candidate_seed=seed,
617
+ ),
618
+ max_candidates=max_candidates,
619
+ preferred_min_speaker_similarity=(
620
+ QUALITY_PREFERRED_MIN_SPEAKER_SIMILARITY
621
+ ),
622
+ preferred_max_boundary_speaker_drop=(
623
+ QUALITY_PREFERRED_MAX_BOUNDARY_SPEAKER_DROP
624
+ ),
625
+ sequence_final_verifier=lambda sequence_result, candidate_chunks: (
626
+ _verify_sequence_trajectory_audio(
627
+ sequence_result,
628
+ candidate_chunks,
629
+ text,
630
+ anchor,
631
+ speed,
632
+ )
633
+ ),
634
+ max_sequence_paths=3,
635
  )
636
+ except NoQualifiedCandidateError as error:
637
+ raise gr.Error("目前沒有候選通過內容與音色驗證,請稍後重試或調整文字。") from error
638
+ except (RuntimeError, ValueError) as error:
639
+ raise gr.Error("品質驗證暫時無法完成,未回傳未驗證的語音。") from error
640
+
641
+ selected_policies = tuple(
642
+ generation_policy_for_candidate_offset(index).name
643
+ for index in cascade.chunk_candidate_indices
644
+ )
645
+ attempted_policies = tuple(
646
+ generation_policy_for_candidate_offset(seed - request_seed).name
647
+ for seed in cascade.attempted_seeds
648
+ )
649
+ print(
650
+ "[BlueMagpie] quality cascade "
651
+ f"candidate_index={cascade.candidate_index} attempts={len(cascade.attempted_seeds)} "
652
+ f"candidate_limit={max_candidates} selection={cascade.selection_mode} "
653
+ f"chunk_candidates={cascade.chunk_candidate_indices} "
654
+ f"chunk_seeds={cascade.chunk_seeds} score={cascade.verification.score:.6f}"
655
+ f" chunk_policies={selected_policies} attempted_policies={attempted_policies}"
656
+ f" sequence_rank={cascade.sequence_path_rank}"
657
+ f" sequence_paths_checked={cascade.sequence_paths_checked}"
658
+ )
659
+ waveform = _assemble_trajectory_audio(cascade.trajectory, chunks, speed)
660
+ final_verification = _verify_trajectory_audio(
661
+ (waveform,),
662
+ (text,),
663
+ anchor,
664
+ 1.0,
665
+ QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
666
+ )
667
+ try:
668
+ require_verified_final_output(final_verification)
669
+ except FinalOutputRejectedError as error:
670
+ print(
671
+ "[BlueMagpie] final output rejected "
672
+ f"{_verification_metric_log_fields(final_verification)}"
673
+ )
674
+ raise gr.Error("最終合成結果未通過整段內容、語速與音色驗證,未回傳音訊。") from error
675
+ print(
676
+ "[BlueMagpie] final output verified "
677
+ f"score={final_verification.score:.6f}"
678
  )
679
+ return SR, waveform
 
 
680
 
681
 
682
  @gpu
 
760
 
761
  台灣華語與中英混合文字轉語音。模型版本:`{CHECKPOINT}`。
762
 
763
+ 執行環境固定於已驗證的 model revision `{MODEL_REVISION[:8]}`ECAPA revision
764
+ `{ECAPA_REVISION[:8]}` 與 Whisper revision `{WHISPER_REVISION[:8]}`,避免服務重啟時
765
+ 無聲變更權重、speaker embedding 或語意驗證空間。
766
 
767
  目前預設採用穩定推論設定:`CFG 2.0`(極短句最低 `3.0`)、`NFE 10`、目標語速 `4.0 字/秒`、
768
+ candidate 0 使用 base duration estimate({BASE_GENERATION_POLICY.cjk_cps:.1f} CJK /
769
+ {BASE_GENERATION_POLICY.ascii_cps:.1f} ASCII)後續候選使用 safe duration estimate
770
+ ({SAFE_DURATION_GENERATION_POLICY.cjk_cps:.1f} CJK /
771
+ {SAFE_DURATION_GENERATION_POLICY.ascii_cps:.1f} ASCII);兩者只調整生成上限,生成完成後才校正至
772
+ 目標語速。另補齊句末提示、套用尾端 weak-stop 保護、
773
+ 只在自然標點切開首段、每 80 字切段;先選完整 same-seed trajectory,失敗時才以 speaker/RMS
774
+ transition 做逐 chunk DP fallback。短句最多擴展到 1→5→10,長文依 chunk 數縮小候選上限,
775
+ 確保每個 request 最多生成 20 個 TTS chunks。
776
  """
777
 
778
 
 
783
  with gr.Row():
784
  cfg_input = gr.Slider(1.0, 4.0, value=DEFAULT_CFG, step=0.1, label="CFG")
785
  steps_input = gr.Slider(4, 20, value=DEFAULT_STEPS, step=1, label="NFE steps")
786
+ speed_input = gr.Slider(0.85, 1.05, value=1.0, step=0.05, label="後處理語速")
787
 
788
  with gr.Tab("內建語者"):
789
  with gr.Row():
bluemagpie/LICENSE.upstream ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 OpenFormosa
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
bluemagpie/UPSTREAM_RUNTIME.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vendored BlueMagpie runtime
2
+
3
+ The Python runtime in this directory is copied byte-for-byte (apart from
4
+ excluded optional `mlx`, `serving`, and cache directories) from:
5
+
6
+ - Repository: `https://github.com/OpenFormosa/BlueMagpie-TTS`
7
+ - Commit: `ce384c8cc54efea1aaba7b9f1d7ded6c1c99aa9a`
8
+
9
+ It is vendored so a Hugging Face Space rebuild cannot silently resolve a
10
+ different BlueMagpie or transitive Barbet revision. The upstream Apache-2.0
11
+ license is preserved in `LICENSE.upstream`; the embedded VoxCPM attribution is
12
+ preserved under `_vendor/voxcpm/`.
bluemagpie/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BlueMagpie-TTS: Barbet TSLM on top of VoxCPM2's acoustic stack."""
2
+
3
+ from .adapter import ProjectionAdapter
4
+ from .centroid import extract_speaker_centroid
5
+ from .conditioning import SpeakerProjector
6
+ from .config import AdapterConfig, BlueMagpieConfig
7
+ from .loading import (
8
+ build_config_from_voxcpm2,
9
+ build_from_pretrained,
10
+ load_voxcpm2_teacher,
11
+ set_training_stage,
12
+ )
13
+ from .model import BlueMagpieModel
14
+ from .tslm import BarbetStepState, BarbetTSLM
15
+
16
+ __all__ = [
17
+ "AdapterConfig",
18
+ "BlueMagpieConfig",
19
+ "BlueMagpieModel",
20
+ "BarbetTSLM",
21
+ "BarbetStepState",
22
+ "ProjectionAdapter",
23
+ "SpeakerProjector",
24
+ "extract_speaker_centroid",
25
+ "build_config_from_voxcpm2",
26
+ "build_from_pretrained",
27
+ "load_voxcpm2_teacher",
28
+ "set_training_stage",
29
+ ]
bluemagpie/_vendor/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Vendored third-party code bundled with BlueMagpie-TTS.
2
+
3
+ Subpackages here are copied from external projects so that BlueMagpie-TTS has no
4
+ external git dependency on them. See each subpackage's ``PROVENANCE.md`` /
5
+ ``LICENSE`` for source and licensing.
6
+ """
bluemagpie/_vendor/voxcpm/LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright OpenBMB
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
bluemagpie/_vendor/voxcpm/PROVENANCE.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vendored VoxCPM (inference subset)
2
+
3
+ This directory contains a **partial copy** of [VoxCPM](https://github.com/OpenBMB/VoxCPM),
4
+ bundled into BlueMagpie-TTS so the package has no external git dependency on
5
+ `voxcpm`.
6
+
7
+ - **Source:** https://github.com/OpenBMB/VoxCPM.git
8
+ - **Commit:** `856d2fc2a853656e324e491706d1e8a6bfac361c`
9
+ - **License:** Apache-2.0 (see `LICENSE` in this directory), Copyright OpenBMB.
10
+
11
+ ## What was copied
12
+
13
+ Only the modules BlueMagpie-TTS uses at inference time:
14
+
15
+ - `model/utils.py` — runtime helpers (`get_dtype`, `next_and_close`,
16
+ `pick_runtime_dtype`, `resolve_runtime_device`).
17
+ - `model/voxcpm2.py` — VoxCPM2 config classes (`VoxCPMConfig`,
18
+ `VoxCPMDitConfig`, `VoxCPMEncoderConfig`) and the `VoxCPM2Model` reference
19
+ implementation.
20
+ - `modules/audiovae/`, `modules/layers/`, `modules/locdit/`, `modules/locenc/`,
21
+ `modules/minicpm4/` — the AudioVAE / LocEnc / RALM / LocDiT acoustic stack.
22
+
23
+ ## What was changed
24
+
25
+ - The package `__init__.py` files (`voxcpm/__init__.py`, `voxcpm/model/__init__.py`)
26
+ were replaced with empty stubs so importing this subset does **not** pull in
27
+ the upstream `core` pipeline or the v1 `VoxCPMModel`.
28
+ - Nothing else was modified; all module source is upstream-verbatim, and all
29
+ internal imports are relative, so the subtree is self-contained.
30
+
31
+ ## Updating
32
+
33
+ To refresh against a newer VoxCPM release, re-copy the files listed above from
34
+ the upstream commit, keep the two stub `__init__.py` files, and re-run the test
35
+ suite.
bluemagpie/_vendor/voxcpm/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vendored subset of VoxCPM (https://github.com/OpenBMB/VoxCPM), Apache-2.0.
2
+
3
+ Only the modules BlueMagpie-TTS needs at inference time are bundled here:
4
+ VoxCPM2's acoustic stack (AudioVAE / LocEnc / RALM / LocDiT) and its config /
5
+ runtime helpers. The original top-level ``voxcpm`` package eagerly imported the
6
+ full ``VoxCPM`` pipeline (``core``), which pulls in dependencies BlueMagpie-TTS
7
+ does not use, so this ``__init__`` is intentionally empty.
8
+
9
+ Modified from upstream: the original ``voxcpm/__init__.py`` imported
10
+ ``from .core import VoxCPM``; that line was removed and replaced with this stub.
11
+
12
+ Import the concrete submodules directly, e.g.::
13
+
14
+ from bluemagpie._vendor.voxcpm.modules.audiovae import AudioVAEV2
15
+
16
+ See ``LICENSE`` and ``PROVENANCE.md`` in this directory for details.
17
+ """
bluemagpie/_vendor/voxcpm/model/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VoxCPM2 model package (vendored subset).
2
+
3
+ The upstream ``voxcpm.model`` package also exposed the v1 ``VoxCPMModel``;
4
+ BlueMagpie-TTS only uses VoxCPM2, so this ``__init__`` is empty and callers
5
+ import the concrete submodules directly.
6
+
7
+ Modified from upstream: the original ``model/__init__.py`` imported
8
+ ``from .voxcpm import VoxCPMModel`` and ``from .voxcpm2 import VoxCPM2Model``;
9
+ those lines were removed and replaced with this stub (the v1 ``voxcpm.py`` is
10
+ not vendored). Example::
11
+
12
+ from bluemagpie._vendor.voxcpm.model.voxcpm2 import VoxCPMConfig
13
+ from bluemagpie._vendor.voxcpm.model.utils import get_dtype
14
+ """
bluemagpie/_vendor/voxcpm/model/utils.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List, Optional
3
+ import torch
4
+ from transformers import PreTrainedTokenizer
5
+
6
+ _LOW_PRECISION_DTYPES = {"bfloat16", "bf16", "float16", "fp16"}
7
+ _VALID_DTYPE_OVERRIDES = {
8
+ "bfloat16", "bf16",
9
+ "float16", "fp16",
10
+ "float32", "fp32",
11
+ }
12
+
13
+
14
+ # Ref: https://github.com/OpenBMB/VoxCPM/issues/256#issuecomment-4235252732
15
+ # Explicitly close partially-consumed generators so inference_mode cleanup
16
+ # does not get deferred to Python's GC/finalizer path.
17
+ def next_and_close(gen):
18
+ try:
19
+ return next(gen)
20
+ finally:
21
+ gen.close()
22
+
23
+
24
+ def mask_multichar_chinese_tokens(tokenizer: PreTrainedTokenizer):
25
+ """Create a tokenizer wrapper that converts multi-character Chinese tokens to single characters.
26
+
27
+ This function creates a wrapper around the provided tokenizer that automatically
28
+ splits multi-character Chinese tokens into individual characters. This is useful
29
+ for ensuring consistent tokenization of Chinese text.
30
+
31
+ Args:
32
+ tokenizer: The base tokenizer to wrap
33
+
34
+ Returns:
35
+ A CharTokenizerWrapper instance that handles multi-character Chinese tokens
36
+
37
+ Example:
38
+ >>> from transformers import LlamaTokenizerFast
39
+ >>> tokenizer = LlamaTokenizerFast.from_pretrained("path/to/tokenizer")
40
+ >>> wrapped_tokenizer = mask_multichar_chinese_tokens(tokenizer)
41
+ >>> tokens = wrapped_tokenizer("你好世界")
42
+ """
43
+ # Pre-compute multi-character tokens (length >= 2, pure Chinese characters)
44
+ multichar_tokens = {
45
+ token for token in tokenizer.vocab.keys() if len(token) >= 2 and all("\u4e00" <= c <= "\u9fff" for c in token)
46
+ }
47
+
48
+ class CharTokenizerWrapper:
49
+ """Wrapper class for tokenizers that handles multi-character Chinese tokens.
50
+
51
+ This wrapper automatically splits multi-character Chinese tokens into
52
+ individual characters while preserving the original tokenizer's interface.
53
+ """
54
+
55
+ def __init__(self, base_tokenizer: PreTrainedTokenizer) -> None:
56
+ """Initialize the wrapper with a base tokenizer.
57
+
58
+ Args:
59
+ base_tokenizer: The tokenizer to wrap
60
+ """
61
+ self.tokenizer = base_tokenizer
62
+ self.multichar_tokens = multichar_tokens
63
+
64
+ def tokenize(self, text: str, **kwargs) -> List[str]:
65
+ """Tokenize text and split multi-character Chinese tokens into single characters.
66
+
67
+ Args:
68
+ text: Input text to tokenize
69
+ **kwargs: Additional arguments passed to the base tokenizer
70
+
71
+ Returns:
72
+ List of processed tokens with multi-character Chinese tokens split
73
+
74
+ Example:
75
+ >>> wrapper = CharTokenizerWrapper(tokenizer)
76
+ >>> tokens = wrapper.tokenize("你好世界")
77
+ >>> # Returns ["你", "好", "世", "界"] instead of ["你好", "世界"]
78
+ """
79
+ if not isinstance(text, str):
80
+ raise TypeError(f"Expected string input, got {type(text)}")
81
+
82
+ tokens = self.tokenizer.tokenize(text, **kwargs)
83
+ processed = []
84
+
85
+ for token in tokens:
86
+ # Remove possible subword prefix
87
+ clean_token = token.replace("▁", "")
88
+
89
+ if clean_token in self.multichar_tokens:
90
+ # Split multi-character token into single characters
91
+ chars = list(clean_token)
92
+ processed.extend(chars)
93
+ else:
94
+ processed.append(token)
95
+
96
+ return processed
97
+
98
+ def __call__(self, text: str, **kwargs) -> List[int]:
99
+ """Call the tokenizer and return token IDs.
100
+
101
+ This method provides the same interface as the original tokenizer
102
+ but with multi-character Chinese token handling.
103
+
104
+ Args:
105
+ text: Input text to tokenize
106
+ **kwargs: Additional arguments passed to the base tokenizer
107
+
108
+ Returns:
109
+ List of token IDs
110
+
111
+ Raises:
112
+ TypeError: If input is not a string
113
+ ValueError: If tokenization fails
114
+ """
115
+ try:
116
+ tokens = self.tokenize(text, **kwargs)
117
+ result = self.tokenizer.convert_tokens_to_ids(tokens)
118
+ return result
119
+ except Exception as e:
120
+ raise ValueError(f"Tokenization failed: {str(e)}") from e
121
+
122
+ return CharTokenizerWrapper(tokenizer)
123
+
124
+
125
+ def get_dtype(dtype: str):
126
+ if dtype == "bfloat16":
127
+ return torch.bfloat16
128
+ elif dtype == "bf16":
129
+ return torch.bfloat16
130
+ elif dtype == "float16":
131
+ return torch.float16
132
+ elif dtype == "fp16":
133
+ return torch.float16
134
+ elif dtype == "float32":
135
+ return torch.float32
136
+ elif dtype == "fp32":
137
+ return torch.float32
138
+ else:
139
+ raise ValueError(f"Unsupported dtype: {dtype}")
140
+
141
+
142
+ def _has_mps() -> bool:
143
+ return hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
144
+
145
+
146
+ def pick_runtime_dtype(device: str, configured_dtype: str) -> str:
147
+ """Pick a safe runtime dtype for the resolved device.
148
+
149
+ On Apple Silicon (MPS), bfloat16/float16 produce enough numerical drift
150
+ in the diffusion AR loop that the output is glitched and the model's
151
+ badcase detector triggers infinite retries. float32 is the only stable
152
+ option today. CUDA and CPU keep whatever the checkpoint was trained with.
153
+
154
+ Users can override with ``VOXCPM_MPS_DTYPE`` (e.g. ``bfloat16``) when
155
+ they want to test future MPS improvements.
156
+ """
157
+ if device != "mps":
158
+ return configured_dtype
159
+
160
+ override = os.environ.get("VOXCPM_MPS_DTYPE", "").strip().lower()
161
+ if override:
162
+ if override not in _VALID_DTYPE_OVERRIDES:
163
+ raise ValueError(
164
+ f"VOXCPM_MPS_DTYPE='{override}' is not one of "
165
+ f"{sorted(_VALID_DTYPE_OVERRIDES)}"
166
+ )
167
+ return override
168
+
169
+ if (configured_dtype or "").lower() in _LOW_PRECISION_DTYPES:
170
+ return "float32"
171
+ return configured_dtype
172
+
173
+
174
+ def auto_select_device(preferred_device: Optional[str] = "cuda") -> str:
175
+ """
176
+ Choose a runtime device automatically.
177
+
178
+ Preference order:
179
+ - if the preferred device is available, use it
180
+ - otherwise fall back to CUDA -> MPS -> CPU
181
+ """
182
+ preferred = (preferred_device or "cuda").strip().lower()
183
+
184
+ if preferred.startswith("cuda") and torch.cuda.is_available():
185
+ return preferred
186
+ if preferred == "mps" and _has_mps():
187
+ return "mps"
188
+ if preferred == "cpu":
189
+ return "cpu"
190
+
191
+ if torch.cuda.is_available():
192
+ return "cuda"
193
+ if _has_mps():
194
+ return "mps"
195
+ return "cpu"
196
+
197
+
198
+ def resolve_runtime_device(device: Optional[str], configured_device: str = "cuda") -> str:
199
+ """
200
+ Resolve the actual runtime device.
201
+
202
+ Semantics:
203
+ - ``device`` is ``None`` or ``"auto"``: use automatic fallback selection
204
+ - otherwise: treat it as an explicit user choice and validate availability
205
+ """
206
+ explicit = None if device is None else device.strip().lower()
207
+
208
+ if explicit is None or explicit == "auto":
209
+ return auto_select_device(configured_device)
210
+
211
+ if explicit.startswith("cuda"):
212
+ if not torch.cuda.is_available():
213
+ raise ValueError(
214
+ f"Requested device '{device}', but CUDA is not available. "
215
+ "Use device='auto' for automatic fallback."
216
+ )
217
+ return explicit
218
+ if explicit == "mps":
219
+ if not _has_mps():
220
+ raise ValueError(
221
+ "Requested device 'mps', but MPS is not available. "
222
+ "Use device='auto' for automatic fallback."
223
+ )
224
+ return "mps"
225
+ if explicit == "cpu":
226
+ return "cpu"
227
+
228
+ raise ValueError(
229
+ f"Unsupported device '{device}'. Supported values are 'auto', 'cpu', 'mps', "
230
+ "'cuda', or indexed CUDA devices like 'cuda:0'."
231
+ )
bluemagpie/_vendor/voxcpm/model/voxcpm2.py ADDED
@@ -0,0 +1,1256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VoxCPM: A Tokenizer-free speech generation model
3
+
4
+ This module contains the main VoxCPM model implementation, including configuration classes
5
+ and the core VoxCPMModel for text-to-speech generation.
6
+
7
+ Copyright 2026 OpenBMB
8
+ Licensed under the Apache License, Version 2.0 (the "License");
9
+ you may not use this file except in compliance with the License.
10
+ You may obtain a copy of the License at
11
+
12
+ http://www.apache.org/licenses/LICENSE-2.0
13
+
14
+ Unless required by applicable law or agreed to in writing, software
15
+ distributed under the License is distributed on an "AS IS" BASIS,
16
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ See the License for the specific language governing permissions and
18
+ limitations under the License.
19
+ """
20
+
21
+ import os
22
+ import sys
23
+ from typing import Tuple, Union, Generator, List, Optional
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+ import warnings
28
+ import librosa
29
+ import numpy as np
30
+ from einops import rearrange
31
+ from pydantic import BaseModel
32
+
33
+ try:
34
+ from safetensors.torch import load_file
35
+
36
+ SAFETENSORS_AVAILABLE = True
37
+ except ImportError:
38
+ SAFETENSORS_AVAILABLE = False
39
+ from tqdm import tqdm
40
+ from transformers import LlamaTokenizerFast
41
+
42
+ from ..modules.audiovae import AudioVAEV2, AudioVAEConfigV2
43
+ from ..modules.layers import ScalarQuantizationLayer
44
+ from ..modules.layers.lora import apply_lora_to_named_linear_modules
45
+ from ..modules.locdit import CfmConfig, UnifiedCFM, VoxCPMLocDiTV2
46
+ from ..modules.locenc import VoxCPMLocEnc
47
+ from ..modules.minicpm4 import MiniCPM4Config, MiniCPMModel
48
+ from .utils import (
49
+ get_dtype,
50
+ mask_multichar_chinese_tokens,
51
+ next_and_close,
52
+ pick_runtime_dtype,
53
+ resolve_runtime_device,
54
+ )
55
+
56
+
57
+ # A simple function to trim audio silence using VAD, not used default
58
+ def _trim_audio_silence_vad(audio: torch.Tensor, sample_rate: int, max_silence_ms: float = 200.0, top_db: float = 35.0) -> torch.Tensor:
59
+ if audio.numel() == 0:
60
+ return audio
61
+ y = audio.squeeze(0).numpy()
62
+ n = len(y)
63
+ frame_length = 2048
64
+ hop_length = 512
65
+ ref = np.max(np.abs(y))
66
+ if ref <= 0:
67
+ return audio
68
+ threshold = ref * (10.0 ** (-top_db / 20.0))
69
+
70
+ try:
71
+ _, (start, end) = librosa.effects.trim(
72
+ y, top_db=top_db, ref=np.max, frame_length=frame_length, hop_length=hop_length
73
+ )
74
+ except Exception:
75
+ start, end = 0, n
76
+
77
+ # Find the last frame with continuous energy, trim the long pseudo-silence at the end (low energy background noise, etc.)
78
+ n_frames = max(0, (n - frame_length) // hop_length + 1)
79
+ last_voice_frame = -1
80
+ for j in range(n_frames):
81
+ idx = j * hop_length
82
+ if idx + frame_length > n:
83
+ break
84
+ rms = np.sqrt(np.mean(y[idx : idx + frame_length] ** 2))
85
+ if rms >= threshold:
86
+ last_voice_frame = j
87
+ if last_voice_frame >= 0:
88
+ end_by_vad = min(n, (last_voice_frame + 1) * hop_length + (frame_length - hop_length))
89
+ end = min(end, end_by_vad)
90
+
91
+ max_silence_samples = int(max_silence_ms * sample_rate / 1000.0)
92
+ new_start = max(0, start - max_silence_samples)
93
+ new_end = min(n, end + max_silence_samples)
94
+ return audio[:, new_start:new_end]
95
+
96
+
97
+ class VoxCPMEncoderConfig(BaseModel):
98
+ hidden_dim: int = 1024
99
+ ffn_dim: int = 4096
100
+ num_heads: int = 16
101
+ num_layers: int = 4
102
+ kv_channels: int = None
103
+
104
+
105
+ class VoxCPMDitConfig(BaseModel):
106
+ hidden_dim: int = 1024
107
+ ffn_dim: int = 4096
108
+ num_heads: int = 16
109
+ num_layers: int = 4
110
+ kv_channels: int = None
111
+ dit_mean_mode: bool = False
112
+
113
+ cfm_config: CfmConfig
114
+
115
+
116
+ class VoxCPMConfig(BaseModel):
117
+ lm_config: MiniCPM4Config
118
+ patch_size: int = 4
119
+ feat_dim: int = 64
120
+ residual_lm_num_layers: int = 8
121
+ residual_lm_no_rope: bool = False
122
+ scalar_quantization_latent_dim: int = 512
123
+ scalar_quantization_scale: int = 9
124
+
125
+ encoder_config: VoxCPMEncoderConfig
126
+ dit_config: VoxCPMDitConfig
127
+ audio_vae_config: Optional[AudioVAEConfigV2] = None
128
+
129
+ max_length: int = 8192
130
+ device: str = "cuda"
131
+ dtype: str = "bfloat16"
132
+
133
+
134
+ class LoRAConfig(BaseModel):
135
+ enable_lm: bool = False # Apply LoRA to base_lm + residual_lm
136
+ enable_dit: bool = False # Apply LoRA to VoxCPMLocDiT
137
+ enable_proj: bool = False # Apply LoRA to projection Linear layers
138
+
139
+ r: int = 8
140
+ alpha: int = 16
141
+ dropout: float = 0.0
142
+
143
+ # Target linear layer names for LM & DiT (matched by attribute name)
144
+ target_modules_lm: list[str] = ["q_proj", "v_proj", "k_proj", "o_proj"]
145
+ target_modules_dit: list[str] = ["q_proj", "v_proj", "k_proj", "o_proj"]
146
+ # Projection layer attribute names to find on VoxCPM2Model
147
+ target_proj_modules: list[str] = ["enc_to_lm_proj", "lm_to_dit_proj", "res_to_dit_proj", "fusion_concat_proj"]
148
+
149
+
150
+ VoxCPMConfig.model_rebuild()
151
+
152
+
153
+ class VoxCPM2Model(nn.Module):
154
+ def __init__(
155
+ self,
156
+ config: VoxCPMConfig,
157
+ tokenizer: LlamaTokenizerFast,
158
+ audio_vae: AudioVAEV2,
159
+ lora_config: LoRAConfig = None,
160
+ device: str | None = None,
161
+ ):
162
+ super().__init__()
163
+ self.config = config
164
+ self.lora_config = lora_config
165
+ self.feat_dim = config.feat_dim
166
+ self.patch_size = config.patch_size
167
+ self.device = resolve_runtime_device(device, config.device)
168
+ self.config.device = self.device
169
+ resolved_dtype = pick_runtime_dtype(self.device, self.config.dtype)
170
+ if resolved_dtype != self.config.dtype:
171
+ print(
172
+ f"[voxcpm2] adjusted dtype {self.config.dtype} -> {resolved_dtype} for device {self.device}",
173
+ file=sys.stderr,
174
+ )
175
+ self.config.dtype = resolved_dtype
176
+ print(f"Running on device: {self.device}, dtype: {self.config.dtype}", file=sys.stderr)
177
+
178
+ # Text-Semantic LM
179
+ self.base_lm = MiniCPMModel(config.lm_config)
180
+ self.base_lm.setup_cache(1, config.max_length, self.device, get_dtype(self.config.dtype))
181
+
182
+ self.text_tokenizer = mask_multichar_chinese_tokens(tokenizer)
183
+ self.audio_start_token = 101
184
+ self.audio_end_token = 102
185
+ self.ref_audio_start_token = 103
186
+ self.ref_audio_end_token = 104
187
+
188
+ # Residual Acoustic LM
189
+ residual_lm_config = config.lm_config.model_copy(deep=True)
190
+ residual_lm_config.num_hidden_layers = config.residual_lm_num_layers
191
+ residual_lm_config.vocab_size = 0
192
+ residual_lm_config.no_rope = config.residual_lm_no_rope
193
+ self.residual_lm = MiniCPMModel(residual_lm_config)
194
+ self.residual_lm.setup_cache(1, config.max_length, self.device, get_dtype(self.config.dtype))
195
+
196
+ # Local Encoder
197
+ encoder_config = config.lm_config.model_copy(deep=True)
198
+ encoder_config.hidden_size = config.encoder_config.hidden_dim
199
+ encoder_config.intermediate_size = config.encoder_config.ffn_dim
200
+ encoder_config.num_attention_heads = config.encoder_config.num_heads
201
+ encoder_config.num_hidden_layers = config.encoder_config.num_layers
202
+ encoder_config.kv_channels = config.encoder_config.kv_channels
203
+ encoder_config.vocab_size = 0
204
+ self.feat_encoder = VoxCPMLocEnc(encoder_config, input_dim=config.feat_dim)
205
+
206
+ # Local DiT
207
+ decoder_config = config.lm_config.model_copy(deep=True)
208
+ decoder_config.hidden_size = config.dit_config.hidden_dim
209
+ decoder_config.intermediate_size = config.dit_config.ffn_dim
210
+ decoder_config.num_attention_heads = config.dit_config.num_heads
211
+ decoder_config.num_hidden_layers = config.dit_config.num_layers
212
+ decoder_config.kv_channels = config.dit_config.kv_channels
213
+ decoder_config.vocab_size = 0
214
+ self.feat_decoder = UnifiedCFM(
215
+ in_channels=config.feat_dim,
216
+ cfm_params=config.dit_config.cfm_config,
217
+ estimator=VoxCPMLocDiTV2(decoder_config, in_channels=config.feat_dim),
218
+ mean_mode=config.dit_config.dit_mean_mode,
219
+ )
220
+
221
+ # Projection layers
222
+ self.fsq_layer = ScalarQuantizationLayer(
223
+ config.lm_config.hidden_size,
224
+ config.lm_config.hidden_size,
225
+ config.scalar_quantization_latent_dim,
226
+ config.scalar_quantization_scale,
227
+ )
228
+ self.enc_to_lm_proj = nn.Linear(config.encoder_config.hidden_dim, config.lm_config.hidden_size)
229
+ self.lm_to_dit_proj = nn.Linear(config.lm_config.hidden_size, config.dit_config.hidden_dim)
230
+ self.res_to_dit_proj = nn.Linear(config.lm_config.hidden_size, config.dit_config.hidden_dim)
231
+ self.fusion_concat_proj = nn.Linear(config.lm_config.hidden_size * 2, config.lm_config.hidden_size)
232
+
233
+ # Stop Predictor
234
+ self.stop_proj = nn.Linear(config.lm_config.hidden_size, config.lm_config.hidden_size)
235
+ self.stop_actn = nn.SiLU()
236
+ self.stop_head = nn.Linear(config.lm_config.hidden_size, 2, bias=False)
237
+ self.stop_loss = nn.CrossEntropyLoss(reduction="none")
238
+
239
+ # Audio VAE
240
+ self.audio_vae = audio_vae
241
+ self.chunk_size = audio_vae.chunk_size
242
+ self._decode_chunk_size = getattr(audio_vae, "decode_chunk_size", audio_vae.chunk_size)
243
+ self._encode_sample_rate = audio_vae.sample_rate
244
+ self.sample_rate = getattr(audio_vae, "out_sample_rate", audio_vae.sample_rate)
245
+
246
+ if self.lora_config is not None:
247
+ self._apply_lora()
248
+
249
+ def _apply_lora(self):
250
+ """注入 LoRA 到 LM / DiT / 投影层"""
251
+ cfg = self.lora_config
252
+ lora_kwargs = dict(r=cfg.r, alpha=cfg.alpha, dropout=cfg.dropout)
253
+
254
+ # LM: base_lm + residual_lm
255
+ if cfg.enable_lm:
256
+ for lm in [self.base_lm, self.residual_lm]:
257
+ apply_lora_to_named_linear_modules(lm, target_submodule_names=cfg.target_modules_lm, **lora_kwargs)
258
+
259
+ # DiT: feat_decoder.estimator
260
+ if cfg.enable_dit:
261
+ apply_lora_to_named_linear_modules(
262
+ self.feat_decoder.estimator, target_submodule_names=cfg.target_modules_dit, **lora_kwargs
263
+ )
264
+
265
+ # 投影层
266
+ if cfg.enable_proj:
267
+ from ..modules.layers.lora import LoRALinear
268
+
269
+ for attr_name in cfg.target_proj_modules:
270
+ module = getattr(self, attr_name, None)
271
+ if isinstance(module, nn.Linear):
272
+ setattr(self, attr_name, LoRALinear(base=module, **lora_kwargs))
273
+
274
+ def optimize(self, disable: bool = False):
275
+ if disable:
276
+ return self
277
+ try:
278
+ if self.device != "cuda":
279
+ raise ValueError("VoxCPMModel can only be optimized on CUDA device")
280
+ try:
281
+ import triton # noqa: F401
282
+ except ImportError:
283
+ raise ValueError("triton is not installed")
284
+ self.base_lm.forward_step = torch.compile(self.base_lm.forward_step, mode="reduce-overhead", fullgraph=True)
285
+ self.residual_lm.forward_step = torch.compile(
286
+ self.residual_lm.forward_step, mode="reduce-overhead", fullgraph=True
287
+ )
288
+ self._feat_encoder_raw = self.feat_encoder
289
+ self.feat_encoder = torch.compile(self.feat_encoder, mode="reduce-overhead", fullgraph=True)
290
+ self.feat_decoder.estimator = torch.compile(
291
+ self.feat_decoder.estimator, mode="reduce-overhead", fullgraph=True
292
+ )
293
+ except Exception as e:
294
+ print(f"Warning: torch.compile disabled - {e}", file=sys.stderr)
295
+ return self
296
+
297
+ def forward(
298
+ self,
299
+ text_tokens: torch.Tensor,
300
+ text_mask: torch.Tensor,
301
+ audio_feats: torch.Tensor,
302
+ audio_mask: torch.Tensor,
303
+ loss_mask: torch.Tensor,
304
+ position_ids: torch.Tensor,
305
+ labels: torch.Tensor,
306
+ *,
307
+ progress: float = 0.0,
308
+ sample_generate: bool = False,
309
+ ):
310
+ del position_ids # not used yet
311
+
312
+ text_tokens = text_tokens.to(self.device, dtype=torch.long)
313
+ text_mask = text_mask.to(self.device, dtype=self._dtype())
314
+ audio_feats = audio_feats.to(self.device, dtype=self._dtype())
315
+ audio_mask = audio_mask.to(self.device, dtype=self._dtype())
316
+ loss_mask = loss_mask.to(self.device, dtype=self._dtype())
317
+ labels = labels.to(self.device, dtype=torch.long)
318
+
319
+ B, T, P, D = audio_feats.shape
320
+ feat_embed = self.feat_encoder(audio_feats)
321
+ feat_embed = self.enc_to_lm_proj(feat_embed)
322
+
323
+ scale_emb = getattr(self.config.lm_config, "scale_emb", 1.0)
324
+ if not getattr(self.config.lm_config, "use_mup", False):
325
+ scale_emb = 1.0
326
+ text_embed = self.base_lm.embed_tokens(text_tokens) * scale_emb
327
+ combined_embed = text_mask.unsqueeze(-1) * text_embed + audio_mask.unsqueeze(-1) * feat_embed
328
+
329
+ enc_outputs, _ = self.base_lm(inputs_embeds=combined_embed, is_causal=True)
330
+ enc_outputs = enc_outputs.to(self._dtype())
331
+ enc_outputs = self.fsq_layer(enc_outputs) * audio_mask.unsqueeze(-1) + enc_outputs * text_mask.unsqueeze(-1)
332
+ lm_hidden = torch.cat((torch.zeros_like(enc_outputs[:, 0:1, :]), enc_outputs[:, :-1, :]), dim=1)
333
+
334
+ residual_inputs = self.fusion_concat_proj(
335
+ torch.cat((enc_outputs, audio_mask.unsqueeze(-1) * feat_embed), dim=-1)
336
+ )
337
+ residual_outputs, _ = self.residual_lm(inputs_embeds=residual_inputs, is_causal=True)
338
+ residual_outputs = residual_outputs.to(self._dtype())
339
+ residual_hidden = torch.cat(
340
+ (torch.zeros_like(residual_outputs[:, 0:1, :]), residual_outputs[:, :-1, :]),
341
+ dim=1,
342
+ )
343
+
344
+ dit_hidden = torch.cat((self.lm_to_dit_proj(lm_hidden), self.res_to_dit_proj(residual_hidden)), dim=-1)
345
+ dit_hidden = rearrange(dit_hidden, "b t c -> (b t) c")
346
+
347
+ # Keep diffusion inputs in the same dtype as the model (e.g., bfloat16)
348
+ target_dtype = self._dtype()
349
+
350
+ feat_gt = rearrange(audio_feats.to(target_dtype), "b t p d -> (b t) p d")
351
+ feat_cond = torch.cat(
352
+ (torch.zeros_like(audio_feats[:, 0:1, ...]), audio_feats[:, :-1, ...]),
353
+ dim=1,
354
+ )
355
+ feat_cond = rearrange(feat_cond.to(target_dtype), "b t p d -> (b t) p d")
356
+
357
+ loss_seq_mask = loss_mask.unsqueeze(-1).repeat(1, 1, self.patch_size)
358
+ loss_seq_mask = rearrange(loss_seq_mask, "b t p -> (b t) p 1").to(target_dtype)
359
+
360
+ diff_loss = self.feat_decoder.compute_loss(
361
+ feat_gt.transpose(1, 2).contiguous(),
362
+ dit_hidden,
363
+ cond=feat_cond.transpose(1, 2).contiguous(),
364
+ tgt_mask=loss_seq_mask.transpose(1, 2).contiguous(),
365
+ progress=progress,
366
+ )
367
+
368
+ stop_logits = self.stop_head(self.stop_actn(self.stop_proj(lm_hidden)))
369
+ stop_losses = self.stop_loss(stop_logits.transpose(1, 2), labels)
370
+ denom = torch.clamp(loss_mask.sum(), min=1.0)
371
+ stop_loss = (stop_losses * loss_mask).sum() / denom
372
+
373
+ feat_pred = None
374
+ if sample_generate:
375
+ feat_cond_for_sample = feat_cond.transpose(1, 2).contiguous()
376
+ feat_pred_seq = self.feat_decoder(
377
+ mu=dit_hidden,
378
+ patch_size=self.patch_size,
379
+ cond=feat_cond_for_sample,
380
+ n_timesteps=10,
381
+ )
382
+ feat_pred = rearrange(feat_pred_seq.transpose(1, 2), "(b t) d p -> b d (t p)", b=B, p=self.patch_size)
383
+
384
+ feat_gt_tensor = rearrange(feat_gt, "(b t) p d -> b d (t p)", b=B, p=self.patch_size)
385
+
386
+ return {
387
+ "loss/diff": diff_loss,
388
+ "loss/stop": stop_loss,
389
+ "feat_gt": feat_gt_tensor,
390
+ "feat_pred": feat_pred,
391
+ }
392
+
393
+ def _dtype(self):
394
+ return get_dtype(self.config.dtype)
395
+
396
+ def _encode_wav(
397
+ self,
398
+ wav_path: str,
399
+ padding_mode: str = "right",
400
+ trim_silence_vad: bool = False,
401
+ ) -> torch.Tensor:
402
+ """Load, trim, pad and VAE-encode an audio file.
403
+
404
+ Args:
405
+ wav_path: path to the audio file.
406
+ padding_mode: "right" (default) or "left" padding for alignment.
407
+ trim_silence_vad: whether to apply VAD-based silence trimming.
408
+
409
+ Returns:
410
+ audio_feat: (T, P, D) tensor of latent patches.
411
+ """
412
+ audio, _ = librosa.load(wav_path, sr=self._encode_sample_rate, mono=True)
413
+ audio = torch.from_numpy(audio).unsqueeze(0)
414
+ if trim_silence_vad:
415
+ audio = _trim_audio_silence_vad(audio, self._encode_sample_rate, max_silence_ms=200.0)
416
+ patch_len = self.patch_size * self.chunk_size
417
+ if audio.size(1) % patch_len != 0:
418
+ padding_size = patch_len - audio.size(1) % patch_len
419
+ pad = (padding_size, 0) if padding_mode == "left" else (0, padding_size)
420
+ audio = torch.nn.functional.pad(audio, pad)
421
+ feat = self.audio_vae.encode(audio.to(self.device), self._encode_sample_rate).cpu()
422
+ return feat.view(self.audio_vae.latent_dim, -1, self.patch_size).permute(1, 2, 0)
423
+
424
+ def _make_ref_prefix(self, ref_feat: torch.Tensor, device: torch.device):
425
+ """Build the [ref_start ref_audio ref_end] prefix segments.
426
+
427
+ Returns:
428
+ tokens, feats, text_mask, audio_mask (all 1-D / 2-D tensors)
429
+ """
430
+ ref_len = ref_feat.size(0)
431
+ z1 = torch.zeros((1, self.patch_size, self.audio_vae.latent_dim), dtype=torch.float32, device=device)
432
+ tokens = torch.cat(
433
+ [
434
+ torch.tensor([self.ref_audio_start_token], dtype=torch.int32, device=device),
435
+ torch.zeros(ref_len, dtype=torch.int32, device=device),
436
+ torch.tensor([self.ref_audio_end_token], dtype=torch.int32, device=device),
437
+ ]
438
+ )
439
+ feats = torch.cat([z1, ref_feat, z1], dim=0)
440
+ t_mask = torch.cat(
441
+ [
442
+ torch.tensor([1], dtype=torch.int32),
443
+ torch.zeros(ref_len, dtype=torch.int32),
444
+ torch.tensor([1], dtype=torch.int32),
445
+ ]
446
+ ).to(device)
447
+ a_mask = torch.cat(
448
+ [
449
+ torch.tensor([0], dtype=torch.int32),
450
+ torch.ones(ref_len, dtype=torch.int32),
451
+ torch.tensor([0], dtype=torch.int32),
452
+ ]
453
+ ).to(device)
454
+ return tokens, feats, t_mask, a_mask
455
+
456
+ def generate(self, *args, **kwargs) -> torch.Tensor:
457
+ return next_and_close(self._generate(*args, streaming=False, **kwargs))
458
+
459
+ def generate_streaming(self, *args, **kwargs) -> Generator[torch.Tensor, None, None]:
460
+ return self._generate(*args, streaming=True, **kwargs)
461
+
462
+ @torch.inference_mode()
463
+ def _generate(
464
+ self,
465
+ target_text: str,
466
+ prompt_text: str = "",
467
+ prompt_wav_path: str = "",
468
+ reference_wav_path: str = "",
469
+ min_len: int = 2,
470
+ max_len: int = 2000,
471
+ inference_timesteps: int = 10,
472
+ cfg_value: float = 2.0,
473
+ retry_badcase: bool = False,
474
+ retry_badcase_max_times: int = 3,
475
+ retry_badcase_ratio_threshold: float = 6.0,
476
+ trim_silence_vad: bool = False,
477
+ streaming: bool = False,
478
+ streaming_prefix_len: int = 4,
479
+ ) -> Generator[torch.Tensor, None, None]:
480
+ if retry_badcase and streaming:
481
+ warnings.warn("Retry on bad cases is not supported in streaming mode, setting retry_badcase=False.")
482
+ retry_badcase = False
483
+
484
+ if reference_wav_path and prompt_wav_path:
485
+ # Combined mode: reference isolation prefix + continuation suffix
486
+ text = prompt_text + target_text
487
+ text_token = torch.LongTensor(self.text_tokenizer(text))
488
+ text_token = torch.cat(
489
+ [
490
+ text_token,
491
+ torch.tensor([self.audio_start_token], dtype=torch.int32, device=text_token.device),
492
+ ],
493
+ dim=-1,
494
+ )
495
+ text_length = text_token.shape[0]
496
+
497
+ ref_feat = self._encode_wav(
498
+ reference_wav_path,
499
+ padding_mode="right",
500
+ trim_silence_vad=trim_silence_vad,
501
+ )
502
+ prompt_feat = self._encode_wav(prompt_wav_path, padding_mode="left", trim_silence_vad=trim_silence_vad)
503
+ prompt_audio_length = prompt_feat.size(0)
504
+
505
+ ref_tokens, ref_feats, ref_t_mask, ref_a_mask = self._make_ref_prefix(ref_feat, text_token.device)
506
+
507
+ prompt_pad_token = torch.zeros(prompt_audio_length, dtype=torch.int32, device=text_token.device)
508
+ text_pad_feat = torch.zeros(
509
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
510
+ dtype=torch.float32,
511
+ device=text_token.device,
512
+ )
513
+
514
+ text_token = torch.cat([ref_tokens, text_token, prompt_pad_token])
515
+ audio_feat = torch.cat([ref_feats, text_pad_feat, prompt_feat], dim=0)
516
+ text_mask = torch.cat(
517
+ [
518
+ ref_t_mask,
519
+ torch.ones(text_length, dtype=torch.int32).to(text_token.device),
520
+ torch.zeros(prompt_audio_length, dtype=torch.int32).to(text_token.device),
521
+ ]
522
+ )
523
+ audio_mask = torch.cat(
524
+ [
525
+ ref_a_mask,
526
+ torch.zeros(text_length, dtype=torch.int32).to(text_token.device),
527
+ torch.ones(prompt_audio_length, dtype=torch.int32).to(text_token.device),
528
+ ]
529
+ )
530
+
531
+ elif reference_wav_path:
532
+ # Reference-only mode (prompt isolation)
533
+ text = target_text
534
+ text_token = torch.LongTensor(self.text_tokenizer(text))
535
+ text_token = torch.cat(
536
+ [
537
+ text_token,
538
+ torch.tensor([self.audio_start_token], dtype=torch.int32, device=text_token.device),
539
+ ],
540
+ dim=-1,
541
+ )
542
+ text_length = text_token.shape[0]
543
+
544
+ ref_feat = self._encode_wav(
545
+ reference_wav_path,
546
+ padding_mode="right",
547
+ trim_silence_vad=trim_silence_vad,
548
+ )
549
+ ref_tokens, ref_feats, ref_t_mask, ref_a_mask = self._make_ref_prefix(ref_feat, text_token.device)
550
+
551
+ text_pad_feat = torch.zeros(
552
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
553
+ dtype=torch.float32,
554
+ device=text_token.device,
555
+ )
556
+ text_token = torch.cat([ref_tokens, text_token])
557
+ audio_feat = torch.cat([ref_feats, text_pad_feat], dim=0)
558
+ text_mask = torch.cat(
559
+ [
560
+ ref_t_mask,
561
+ torch.ones(text_length, dtype=torch.int32).to(text_token.device),
562
+ ]
563
+ )
564
+ audio_mask = torch.cat(
565
+ [
566
+ ref_a_mask,
567
+ torch.zeros(text_length, dtype=torch.int32).to(text_token.device),
568
+ ]
569
+ )
570
+
571
+ elif len(prompt_wav_path) == 0:
572
+ # Zero-shot mode
573
+ text = target_text
574
+ text_token = torch.LongTensor(self.text_tokenizer(text))
575
+ text_token = torch.cat(
576
+ [
577
+ text_token,
578
+ torch.tensor([self.audio_start_token], dtype=torch.int32, device=text_token.device),
579
+ ],
580
+ dim=-1,
581
+ )
582
+ text_length = text_token.shape[0]
583
+
584
+ audio_feat = torch.zeros(
585
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
586
+ dtype=torch.float32,
587
+ device=text_token.device,
588
+ )
589
+ text_mask = torch.ones(text_length, dtype=torch.int32).to(text_token.device)
590
+ audio_mask = torch.zeros(text_length, dtype=torch.int32).to(text_token.device)
591
+
592
+ else:
593
+ # Continuation-only mode
594
+ text = prompt_text + target_text
595
+ text_token = torch.LongTensor(self.text_tokenizer(text))
596
+ text_token = torch.cat(
597
+ [
598
+ text_token,
599
+ torch.tensor([self.audio_start_token], dtype=torch.int32, device=text_token.device),
600
+ ],
601
+ dim=-1,
602
+ )
603
+ text_length = text_token.shape[0]
604
+
605
+ prompt_feat = self._encode_wav(prompt_wav_path, padding_mode="left", trim_silence_vad=trim_silence_vad)
606
+ prompt_audio_length = prompt_feat.size(0)
607
+ prompt_pad_token = torch.zeros(prompt_audio_length, dtype=torch.int32, device=text_token.device)
608
+ text_pad_feat = torch.zeros(
609
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
610
+ dtype=torch.float32,
611
+ device=text_token.device,
612
+ )
613
+ text_token = torch.cat([text_token, prompt_pad_token])
614
+ audio_feat = torch.cat([text_pad_feat, prompt_feat], dim=0)
615
+ text_mask = torch.cat(
616
+ [
617
+ torch.ones(text_length, dtype=torch.int32),
618
+ torch.zeros(prompt_audio_length, dtype=torch.int32),
619
+ ]
620
+ ).to(text_token.device)
621
+ audio_mask = torch.cat(
622
+ [
623
+ torch.zeros(text_length, dtype=torch.int32),
624
+ torch.ones(prompt_audio_length, dtype=torch.int32),
625
+ ]
626
+ ).to(text_token.device)
627
+
628
+ text_token = text_token.unsqueeze(0).to(self.device)
629
+ text_mask = text_mask.unsqueeze(0).to(self.device)
630
+ audio_feat = audio_feat.unsqueeze(0).to(self.device).to(get_dtype(self.config.dtype))
631
+ audio_mask = audio_mask.unsqueeze(0).to(self.device)
632
+
633
+ target_text_length = len(self.text_tokenizer(target_text))
634
+
635
+ retry_badcase_times = 0
636
+ while retry_badcase_times < retry_badcase_max_times:
637
+ inference_result = self._inference(
638
+ text_token,
639
+ text_mask,
640
+ audio_feat,
641
+ audio_mask,
642
+ min_len=min_len,
643
+ max_len=min(int(target_text_length * retry_badcase_ratio_threshold + 10), max_len),
644
+ inference_timesteps=inference_timesteps,
645
+ cfg_value=cfg_value,
646
+ streaming=streaming,
647
+ streaming_prefix_len=streaming_prefix_len,
648
+ )
649
+ if streaming:
650
+ with self.audio_vae.streaming_decode() as vae_dec:
651
+ for latent_pred, _, _ctx in inference_result:
652
+ decode_audio = vae_dec.decode_chunk(latent_pred.to(torch.float32))
653
+ decode_audio = decode_audio.squeeze(1).cpu()
654
+ yield decode_audio
655
+ break
656
+ else:
657
+ latent_pred, pred_audio_feat, context_len = next_and_close(inference_result)
658
+ if retry_badcase:
659
+ if pred_audio_feat.shape[0] >= target_text_length * retry_badcase_ratio_threshold:
660
+ print(
661
+ f" Badcase detected, audio_text_ratio={pred_audio_feat.shape[0] / target_text_length}, retrying...",
662
+ file=sys.stderr,
663
+ )
664
+ retry_badcase_times += 1
665
+ continue
666
+ else:
667
+ break
668
+ else:
669
+ break
670
+
671
+ if not streaming:
672
+ decode_audio = self.audio_vae.decode(latent_pred.to(torch.float32))
673
+ decode_patch_len = self.patch_size * self._decode_chunk_size
674
+ if context_len > 0:
675
+ decode_audio = decode_audio[..., decode_patch_len * context_len:].squeeze(1).cpu()
676
+ else:
677
+ decode_audio = decode_audio.squeeze(1).cpu()
678
+ yield decode_audio
679
+
680
+ @torch.inference_mode()
681
+ def build_prompt_cache(
682
+ self,
683
+ prompt_text: str = None,
684
+ prompt_wav_path: str = None,
685
+ reference_wav_path: str = None,
686
+ trim_silence_vad: bool = False,
687
+ ):
688
+ """
689
+ Build prompt cache for subsequent generation.
690
+
691
+ Supports the same parameter combinations as ``generate()``:
692
+ - ``reference_wav_path`` only -> reference mode (voice cloning, isolated)
693
+ - ``prompt_text`` + ``prompt_wav_path`` -> continuation mode
694
+ - all three -> combined ref + continuation mode
695
+
696
+ Args:
697
+ prompt_text: prompt text for continuation mode.
698
+ Must be paired with ``prompt_wav_path``.
699
+ prompt_wav_path: prompt audio path for continuation mode.
700
+ Must be paired with ``prompt_text``.
701
+ reference_wav_path: reference audio path for voice cloning
702
+ (structurally isolated via ref_audio tokens).
703
+ trim_silence_vad: whether to apply VAD-based silence trimming
704
+ before encoding prompt/reference audio.
705
+
706
+ Returns:
707
+ prompt_cache: dict used by ``_generate_with_prompt_cache``.
708
+ """
709
+ if (prompt_wav_path is None) != (prompt_text is None):
710
+ raise ValueError("prompt_wav_path and prompt_text must both be provided or both be None")
711
+ if prompt_wav_path is None and reference_wav_path is None:
712
+ raise ValueError("At least one of prompt_wav_path or reference_wav_path must be provided")
713
+
714
+ cache = {}
715
+
716
+ if reference_wav_path:
717
+ cache["ref_audio_feat"] = self._encode_wav(
718
+ reference_wav_path,
719
+ padding_mode="right",
720
+ trim_silence_vad=trim_silence_vad,
721
+ )
722
+
723
+ if prompt_wav_path and prompt_text is not None:
724
+ cache["prompt_text"] = prompt_text
725
+ cache["audio_feat"] = self._encode_wav(
726
+ prompt_wav_path,
727
+ padding_mode="left",
728
+ trim_silence_vad=trim_silence_vad,
729
+ )
730
+
731
+ has_ref = "ref_audio_feat" in cache
732
+ has_prompt = "audio_feat" in cache
733
+ if has_ref and has_prompt:
734
+ cache["mode"] = "ref_continuation"
735
+ elif has_ref:
736
+ cache["mode"] = "reference"
737
+ else:
738
+ cache["mode"] = "continuation"
739
+
740
+ return cache
741
+
742
+ def merge_prompt_cache(
743
+ self,
744
+ original_cache: dict,
745
+ new_text: str,
746
+ new_audio_feat: torch.Tensor,
747
+ ):
748
+ """
749
+ Merge original prompt cache with newly generated content to stabilize voice.
750
+
751
+ Args:
752
+ original_cache: original prompt cache (any mode)
753
+ new_text: newly generated text
754
+ new_audio_feat: newly generated audio features
755
+
756
+ Returns:
757
+ merged_cache: merged cache with prompt_text and audio_feat
758
+ """
759
+ if original_cache is None:
760
+ return {
761
+ "prompt_text": new_text,
762
+ "audio_feat": new_audio_feat,
763
+ "mode": "continuation",
764
+ }
765
+ merged = {}
766
+ if "ref_audio_feat" in original_cache:
767
+ merged["ref_audio_feat"] = original_cache["ref_audio_feat"]
768
+ merged["prompt_text"] = original_cache.get("prompt_text", "") + new_text
769
+ old_feat = original_cache.get("audio_feat", new_audio_feat.new_empty(0, *new_audio_feat.shape[1:]))
770
+ merged["audio_feat"] = torch.cat([old_feat, new_audio_feat], dim=0)
771
+ merged["mode"] = "ref_continuation" if "ref_audio_feat" in merged else "continuation"
772
+ return merged
773
+
774
+ def generate_with_prompt_cache(self, *args, **kwargs) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
775
+ return next_and_close(self._generate_with_prompt_cache(*args, streaming=False, **kwargs))
776
+
777
+ def generate_with_prompt_cache_streaming(
778
+ self, *args, **kwargs
779
+ ) -> Generator[Tuple[torch.Tensor, torch.Tensor, List[torch.Tensor]], None, None]:
780
+ return self._generate_with_prompt_cache(*args, streaming=True, **kwargs)
781
+
782
+ @torch.inference_mode()
783
+ def _generate_with_prompt_cache(
784
+ self,
785
+ target_text: str,
786
+ prompt_cache: dict,
787
+ min_len: int = 2,
788
+ max_len: int = 2000,
789
+ inference_timesteps: int = 10,
790
+ cfg_value: float = 2.0,
791
+ retry_badcase: bool = False,
792
+ retry_badcase_max_times: int = 3,
793
+ retry_badcase_ratio_threshold: float = 6.0,
794
+ streaming: bool = False,
795
+ streaming_prefix_len: int = 4,
796
+ ) -> Generator[Tuple[torch.Tensor, torch.Tensor, Union[torch.Tensor, List[torch.Tensor]]], None, None]:
797
+ """
798
+ Generate audio using pre-built prompt cache.
799
+
800
+ Args:
801
+ target_text: Text to convert to speech
802
+ prompt_cache: Cache built by ``build_prompt_cache()``. Can be None
803
+ for zero-shot generation.
804
+ min_len: Minimum audio length to avoid very short audio
805
+ max_len: Maximum audio length
806
+ inference_timesteps: Number of diffusion sampling steps
807
+ cfg_value: Classifier-free guidance value
808
+ retry_badcase: Whether to retry on bad cases
809
+ retry_badcase_max_times: Maximum retry attempts
810
+ retry_badcase_ratio_threshold: Threshold for audio-to-text ratio
811
+ streaming: Whether to return a generator of audio chunks
812
+ streaming_prefix_len: Number of prefix audio patches to use for streaming mode
813
+
814
+ Returns:
815
+ Generator of Tuple containing:
816
+ - Decoded audio tensor for the current step if ``streaming=True``, else final decoded audio tensor
817
+ - Tensor of new text tokens
818
+ - New audio features up to the current step as a List if ``streaming=True``, else as a concatenated Tensor
819
+ """
820
+ if retry_badcase and streaming:
821
+ warnings.warn("Retry on bad cases is not supported in streaming mode, setting retry_badcase=False.")
822
+ retry_badcase = False
823
+
824
+ # Determine mode from cache
825
+ if prompt_cache is None:
826
+ mode = "zero_shot"
827
+ text = target_text
828
+ else:
829
+ mode = prompt_cache.get("mode", "continuation")
830
+ if mode in ("continuation", "ref_continuation"):
831
+ prompt_text = prompt_cache.get("prompt_text", "")
832
+ text = prompt_text + target_text
833
+ else:
834
+ text = target_text
835
+
836
+ text_token = torch.LongTensor(self.text_tokenizer(text))
837
+ text_token = torch.cat(
838
+ [
839
+ text_token,
840
+ torch.tensor([self.audio_start_token], dtype=torch.int32, device=text_token.device),
841
+ ],
842
+ dim=-1,
843
+ )
844
+
845
+ target_text_token = torch.LongTensor(self.text_tokenizer(target_text))
846
+ text_length = text_token.shape[0]
847
+
848
+ if mode in ("zero_shot", "continuation"):
849
+ prompt_audio_feat = (
850
+ prompt_cache["audio_feat"]
851
+ if prompt_cache
852
+ else torch.empty((0, self.patch_size, self.audio_vae.latent_dim), dtype=torch.float32)
853
+ )
854
+ audio_length = prompt_audio_feat.size(0)
855
+ text_pad_token = torch.zeros(audio_length, dtype=torch.int32, device=text_token.device)
856
+ text_pad_feat = torch.zeros(
857
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
858
+ dtype=torch.float32,
859
+ device=text_token.device,
860
+ )
861
+ text_token = torch.cat([text_token, text_pad_token])
862
+ audio_feat = torch.cat([text_pad_feat, prompt_audio_feat], dim=0)
863
+ text_mask = torch.cat(
864
+ [torch.ones(text_length, dtype=torch.int32), torch.zeros(audio_length, dtype=torch.int32)]
865
+ ).to(text_token.device)
866
+ audio_mask = torch.cat(
867
+ [torch.zeros(text_length, dtype=torch.int32), torch.ones(audio_length, dtype=torch.int32)]
868
+ ).to(text_token.device)
869
+
870
+ elif mode == "reference":
871
+ ref_audio_feat = prompt_cache["ref_audio_feat"]
872
+ ref_tokens, ref_feats, ref_t_mask, ref_a_mask = self._make_ref_prefix(ref_audio_feat, text_token.device)
873
+ text_pad_feat = torch.zeros(
874
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
875
+ dtype=torch.float32,
876
+ device=text_token.device,
877
+ )
878
+ text_token = torch.cat([ref_tokens, text_token])
879
+ audio_feat = torch.cat([ref_feats, text_pad_feat], dim=0)
880
+ text_mask = torch.cat([ref_t_mask, torch.ones(text_length, dtype=torch.int32).to(text_token.device)])
881
+ audio_mask = torch.cat([ref_a_mask, torch.zeros(text_length, dtype=torch.int32).to(text_token.device)])
882
+
883
+ else:
884
+ # ref_continuation mode
885
+ ref_audio_feat = prompt_cache["ref_audio_feat"]
886
+ prompt_audio_feat = prompt_cache["audio_feat"]
887
+ prompt_audio_length = prompt_audio_feat.size(0)
888
+
889
+ ref_tokens, ref_feats, ref_t_mask, ref_a_mask = self._make_ref_prefix(ref_audio_feat, text_token.device)
890
+
891
+ prompt_pad_token = torch.zeros(prompt_audio_length, dtype=torch.int32, device=text_token.device)
892
+ text_pad_feat = torch.zeros(
893
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
894
+ dtype=torch.float32,
895
+ device=text_token.device,
896
+ )
897
+
898
+ text_token = torch.cat([ref_tokens, text_token, prompt_pad_token])
899
+ audio_feat = torch.cat([ref_feats, text_pad_feat, prompt_audio_feat], dim=0)
900
+ text_mask = torch.cat(
901
+ [
902
+ ref_t_mask,
903
+ torch.ones(text_length, dtype=torch.int32).to(text_token.device),
904
+ torch.zeros(prompt_audio_length, dtype=torch.int32).to(text_token.device),
905
+ ]
906
+ )
907
+ audio_mask = torch.cat(
908
+ [
909
+ ref_a_mask,
910
+ torch.zeros(text_length, dtype=torch.int32).to(text_token.device),
911
+ torch.ones(prompt_audio_length, dtype=torch.int32).to(text_token.device),
912
+ ]
913
+ )
914
+
915
+ text_token = text_token.unsqueeze(0).to(self.device)
916
+ text_mask = text_mask.unsqueeze(0).to(self.device)
917
+ audio_feat = audio_feat.unsqueeze(0).to(self.device).to(get_dtype(self.config.dtype))
918
+ audio_mask = audio_mask.unsqueeze(0).to(self.device)
919
+
920
+ # run inference
921
+ target_text_length = len(self.text_tokenizer(target_text))
922
+ retry_badcase_times = 0
923
+ while retry_badcase_times < retry_badcase_max_times:
924
+ inference_result = self._inference(
925
+ text_token,
926
+ text_mask,
927
+ audio_feat,
928
+ audio_mask,
929
+ min_len=min_len,
930
+ max_len=min(int(target_text_length * retry_badcase_ratio_threshold + 10), max_len),
931
+ inference_timesteps=inference_timesteps,
932
+ cfg_value=cfg_value,
933
+ streaming=streaming,
934
+ streaming_prefix_len=streaming_prefix_len,
935
+ )
936
+ if streaming:
937
+ with self.audio_vae.streaming_decode() as vae_dec:
938
+ for latent_pred, pred_audio_feat, _ctx in inference_result:
939
+ decode_audio = vae_dec.decode_chunk(latent_pred.to(torch.float32))
940
+ decode_audio = decode_audio.squeeze(1).cpu()
941
+ yield (decode_audio, target_text_token, pred_audio_feat)
942
+ break
943
+ else:
944
+ latent_pred, pred_audio_feat, context_len = next_and_close(inference_result)
945
+ if retry_badcase:
946
+ if pred_audio_feat.shape[0] >= target_text_length * retry_badcase_ratio_threshold:
947
+ print(
948
+ f" Badcase detected, audio_text_ratio={pred_audio_feat.shape[0] / target_text_length}, retrying...",
949
+ file=sys.stderr,
950
+ )
951
+ retry_badcase_times += 1
952
+ continue
953
+ else:
954
+ break
955
+ else:
956
+ break
957
+ if not streaming:
958
+ decode_audio = self.audio_vae.decode(latent_pred.to(torch.float32))
959
+ decode_patch_len = self.patch_size * self._decode_chunk_size
960
+ if context_len > 0:
961
+ decode_audio = decode_audio[..., decode_patch_len * context_len:].squeeze(1).cpu()
962
+ else:
963
+ decode_audio = decode_audio.squeeze(1).cpu()
964
+ yield (decode_audio, target_text_token, pred_audio_feat)
965
+
966
+ def inference(self, *args, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
967
+ feat_pred, generated_feat, _ = next_and_close(self._inference(*args, streaming=False, **kwargs))
968
+ return feat_pred, generated_feat
969
+
970
+ def inference_streaming(self, *args, **kwargs) -> Generator[Tuple[torch.Tensor, List[torch.Tensor]], None, None]:
971
+ for feat_pred, pred_feat_seq, _ in self._inference(*args, streaming=True, **kwargs):
972
+ yield feat_pred, pred_feat_seq
973
+
974
+ @torch.inference_mode()
975
+ def _inference(
976
+ self,
977
+ text: torch.Tensor,
978
+ text_mask: torch.Tensor,
979
+ feat: torch.Tensor,
980
+ feat_mask: torch.Tensor,
981
+ min_len: int = 2,
982
+ max_len: int = 2000,
983
+ inference_timesteps: int = 10,
984
+ cfg_value: float = 2.0,
985
+ streaming: bool = False,
986
+ streaming_prefix_len: int = 4,
987
+ ) -> Generator[Tuple[torch.Tensor, Union[torch.Tensor, List[torch.Tensor]]], None, None]:
988
+ """Core inference method for audio generation.
989
+
990
+ This is the main inference loop that generates audio features
991
+ using the language model and diffusion transformer.
992
+
993
+ Args:
994
+ text: Input text tokens
995
+ text_mask: Mask for text tokens
996
+ feat: Input audio features
997
+ feat_mask: Mask for audio features
998
+ min_len: Minimum generation length
999
+ max_len: Maximum generation length
1000
+ inference_timesteps: Number of diffusion steps
1001
+ cfg_value: Classifier-free guidance value
1002
+ streaming: Whether to yield each step latent feature or just the final result
1003
+
1004
+ Returns:
1005
+ Generator of Tuple containing:
1006
+ - Predicted latent feature at the current step if ``streaming=True``, else final latent features
1007
+ - Predicted audio feature sequence so far as a List if ``streaming=True``, else as a concatenated Tensor
1008
+ """
1009
+ B, T, P, D = feat.shape
1010
+
1011
+ prefill_encoder = getattr(self, "_feat_encoder_raw", self.feat_encoder)
1012
+ feat_embed = prefill_encoder(feat) # [b, t, h_feat]
1013
+ feat_embed = self.enc_to_lm_proj(feat_embed)
1014
+
1015
+ if self.config.lm_config.use_mup:
1016
+ scale_emb = self.config.lm_config.scale_emb
1017
+ else:
1018
+ scale_emb = 1.0
1019
+
1020
+ text_embed = self.base_lm.embed_tokens(text) * scale_emb
1021
+ combined_embed = text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed
1022
+
1023
+ prefix_feat_cond = feat[:, -1, ...] # b, p, d
1024
+ pred_feat_seq = [] # b, t, p, d
1025
+ curr_embed = None
1026
+
1027
+ # Prepare prompt context patches for streaming mode
1028
+ # - Continuation modes (feat_mask ends with 1): use the last (streaming_prefix_len - 1)
1029
+ # trailing audio patches as initial context so the VAE can decode smoothly.
1030
+ # - Reference-only / zero-shot (feat_mask ends with 0): start from scratch.
1031
+ has_continuation_audio = feat_mask[0, -1].item() == 1
1032
+ context_len = 0
1033
+ if has_continuation_audio:
1034
+ audio_indices = feat_mask.squeeze(0).nonzero(as_tuple=True)[0]
1035
+ context_len = min(streaming_prefix_len - 1, len(audio_indices))
1036
+ last_audio_indices = audio_indices[-context_len:]
1037
+ pred_feat_seq = list(feat[:, last_audio_indices, :, :].split(1, dim=1))
1038
+ else:
1039
+ pred_feat_seq = []
1040
+
1041
+ enc_outputs, kv_cache_tuple = self.base_lm(
1042
+ inputs_embeds=combined_embed,
1043
+ is_causal=True,
1044
+ )
1045
+ self.base_lm.kv_cache.fill_caches(kv_cache_tuple)
1046
+
1047
+ enc_outputs = self.fsq_layer(enc_outputs) * feat_mask.unsqueeze(-1) + enc_outputs * text_mask.unsqueeze(-1)
1048
+ lm_hidden = enc_outputs[:, -1, :]
1049
+
1050
+ residual_enc_inputs = self.fusion_concat_proj(
1051
+ torch.cat((enc_outputs, feat_mask.unsqueeze(-1) * feat_embed), dim=-1)
1052
+ )
1053
+ residual_enc_outputs, residual_kv_cache_tuple = self.residual_lm(
1054
+ inputs_embeds=residual_enc_inputs,
1055
+ is_causal=True,
1056
+ )
1057
+ self.residual_lm.kv_cache.fill_caches(residual_kv_cache_tuple)
1058
+ residual_hidden = residual_enc_outputs[:, -1, :]
1059
+
1060
+ for i in tqdm(range(max_len)):
1061
+ dit_hidden_1 = self.lm_to_dit_proj(lm_hidden) # [b, h_dit]
1062
+ dit_hidden_2 = self.res_to_dit_proj(residual_hidden) # [b, h_dit]
1063
+ dit_hidden = torch.cat((dit_hidden_1, dit_hidden_2), dim=-1)
1064
+
1065
+ pred_feat = self.feat_decoder(
1066
+ mu=dit_hidden,
1067
+ patch_size=self.patch_size,
1068
+ cond=prefix_feat_cond.transpose(1, 2).contiguous(),
1069
+ n_timesteps=inference_timesteps,
1070
+ cfg_value=cfg_value,
1071
+ ).transpose(
1072
+ 1, 2
1073
+ ) # [b, p, d]
1074
+
1075
+ curr_embed = self.feat_encoder(pred_feat.unsqueeze(1)) # b, 1, c
1076
+ curr_embed = self.enc_to_lm_proj(curr_embed)
1077
+
1078
+ pred_feat_seq.append(pred_feat.unsqueeze(1)) # b, 1, p, d
1079
+ prefix_feat_cond = pred_feat
1080
+
1081
+ if streaming:
1082
+ # Yield only the newest patch latent for stateful VAE decode
1083
+ feat_pred = rearrange(pred_feat.unsqueeze(1), "b t p d -> b d (t p)", b=B, p=self.patch_size)
1084
+
1085
+ yield feat_pred, pred_feat_seq, context_len
1086
+
1087
+ if len(pred_feat_seq) > streaming_prefix_len:
1088
+ pred_feat_seq = pred_feat_seq[-streaming_prefix_len:]
1089
+
1090
+ stop_flag = self.stop_head(self.stop_actn(self.stop_proj(lm_hidden))).argmax(dim=-1)[0].cpu().item()
1091
+ if i > min_len and stop_flag == 1:
1092
+ break
1093
+
1094
+ lm_hidden = self.base_lm.forward_step(
1095
+ curr_embed[:, 0, :], torch.tensor([self.base_lm.kv_cache.step()], device=curr_embed.device)
1096
+ ).clone()
1097
+
1098
+ lm_hidden = self.fsq_layer(lm_hidden)
1099
+ curr_residual_input = self.fusion_concat_proj(torch.cat((lm_hidden, curr_embed[:, 0, :]), dim=-1))
1100
+ residual_hidden = self.residual_lm.forward_step(
1101
+ curr_residual_input, torch.tensor([self.residual_lm.kv_cache.step()], device=curr_embed.device)
1102
+ ).clone()
1103
+
1104
+ if not streaming:
1105
+ pred_feat_seq = torch.cat(pred_feat_seq, dim=1) # b, t, p, d
1106
+ feat_pred = rearrange(pred_feat_seq, "b t p d -> b d (t p)", b=B, p=self.patch_size)
1107
+ generated_feat = pred_feat_seq[:, context_len:, :, :].squeeze(0).cpu()
1108
+ yield feat_pred, generated_feat, context_len
1109
+
1110
+ @classmethod
1111
+ def from_local(
1112
+ cls,
1113
+ path: str,
1114
+ optimize: bool = True,
1115
+ training: bool = False,
1116
+ device: str | None = None,
1117
+ lora_config: LoRAConfig = None,
1118
+ ):
1119
+ with open(os.path.join(path, "config.json"), "r", encoding="utf-8") as _cfg_f:
1120
+ config = VoxCPMConfig.model_validate_json(_cfg_f.read())
1121
+ tokenizer = LlamaTokenizerFast.from_pretrained(path)
1122
+ audio_vae_config = getattr(config, "audio_vae_config", None)
1123
+ audio_vae = AudioVAEV2(config=audio_vae_config) if audio_vae_config else AudioVAEV2()
1124
+ # Try to load AudioVAE from safetensors first, fallback to pytorch
1125
+ audiovae_safetensors_path = os.path.join(path, "audiovae.safetensors")
1126
+ audiovae_pth_path = os.path.join(path, "audiovae.pth")
1127
+ if os.path.exists(audiovae_safetensors_path) and SAFETENSORS_AVAILABLE:
1128
+ print(f"Loading AudioVAE from safetensors: {audiovae_safetensors_path}", file=sys.stderr)
1129
+ vae_state_dict = load_file(audiovae_safetensors_path, device="cpu")
1130
+ elif os.path.exists(audiovae_pth_path):
1131
+ print(f"Loading AudioVAE from pytorch: {audiovae_pth_path}", file=sys.stderr)
1132
+ checkpoint = torch.load(
1133
+ audiovae_pth_path,
1134
+ map_location="cpu",
1135
+ weights_only=True,
1136
+ )
1137
+ vae_state_dict = checkpoint.get("state_dict", checkpoint)
1138
+ else:
1139
+ raise FileNotFoundError(
1140
+ f"AudioVAE checkpoint not found. Expected either {audiovae_safetensors_path} or {audiovae_pth_path}"
1141
+ )
1142
+ model = cls(config, tokenizer, audio_vae, lora_config, device=device)
1143
+ if not training:
1144
+ lm_dtype = get_dtype(model.config.dtype)
1145
+ model = model.to(lm_dtype)
1146
+ else: # training mode
1147
+ for name, param in model.named_parameters():
1148
+ if "audio_vae" in name: # freeze VAE weights
1149
+ param.requires_grad = False
1150
+ continue
1151
+ if lora_config is not None:
1152
+ if "lora" not in name: # freeze non-LoRA weights
1153
+ param.requires_grad = False
1154
+ model.audio_vae = model.audio_vae.to(torch.float32)
1155
+
1156
+ # Try to load from safetensors first, fallback to pytorch_model.bin
1157
+ safetensors_path = os.path.join(path, "model.safetensors")
1158
+ pytorch_model_path = os.path.join(path, "pytorch_model.bin")
1159
+
1160
+ if os.path.exists(safetensors_path) and SAFETENSORS_AVAILABLE:
1161
+ print(f"Loading model from safetensors: {safetensors_path}", file=sys.stderr)
1162
+ model_state_dict = load_file(safetensors_path)
1163
+ elif os.path.exists(pytorch_model_path):
1164
+ print(f"Loading model from pytorch_model.bin: {pytorch_model_path}", file=sys.stderr)
1165
+ checkpoint = torch.load(
1166
+ pytorch_model_path,
1167
+ map_location="cpu",
1168
+ weights_only=True,
1169
+ )
1170
+ model_state_dict = checkpoint.get("state_dict", checkpoint)
1171
+ else:
1172
+ raise FileNotFoundError(f"Model file not found. Expected either {safetensors_path} or {pytorch_model_path}")
1173
+
1174
+ for kw, val in vae_state_dict.items():
1175
+ model_state_dict[f"audio_vae.{kw}"] = val
1176
+
1177
+ # LoRALinear keeps weight/bias compatible with nn.Linear but adds
1178
+ # lora_A/lora_B, which are absent from base pretrained checkpoints.
1179
+ model.load_state_dict(model_state_dict, strict=False)
1180
+ if training:
1181
+ return model
1182
+ return model.to(model.device).eval().optimize(disable=not optimize)
1183
+
1184
+ # ------------------------------------------------------------------ #
1185
+ # LoRA Weight Management
1186
+ # ------------------------------------------------------------------ #
1187
+ def _iter_lora_modules(self):
1188
+ """Iterate over all LoRA modules."""
1189
+ from ..modules.layers.lora import LoRALinear
1190
+
1191
+ for module in self.modules():
1192
+ if isinstance(module, LoRALinear):
1193
+ yield module
1194
+
1195
+ def load_lora_weights(self, lora_path: str, device: str = None):
1196
+ """
1197
+ Load LoRA weights from file, supports calling after torch.compile.
1198
+ Uses named_parameters() to handle compile's _orig_mod wrapper.
1199
+ Supports both safetensors and pytorch formats.
1200
+
1201
+ Args:
1202
+ lora_path: Checkpoint path (directory or .safetensors/.ckpt file)
1203
+ device: Target device, defaults to model's current device
1204
+ Returns:
1205
+ tuple: (loaded_keys, skipped_keys)
1206
+ """
1207
+ from pathlib import Path
1208
+
1209
+ device = device or self.device
1210
+ lora_p = Path(lora_path)
1211
+
1212
+ # Try safetensors first, then fallback to .ckpt
1213
+ if lora_p.is_dir():
1214
+ safetensors_file = lora_p / "lora_weights.safetensors"
1215
+ ckpt_file = lora_p / "lora_weights.ckpt"
1216
+ else:
1217
+ safetensors_file = lora_p if lora_p.suffix == ".safetensors" else None
1218
+ ckpt_file = lora_p if lora_p.suffix in [".ckpt", ".pth"] else None
1219
+
1220
+ # Load from safetensors if available
1221
+ if safetensors_file and safetensors_file.exists() and SAFETENSORS_AVAILABLE:
1222
+ state_dict = load_file(str(safetensors_file), device=device)
1223
+ elif ckpt_file and ckpt_file.exists():
1224
+ ckpt = torch.load(ckpt_file, map_location=device, weights_only=True)
1225
+ state_dict = ckpt.get("state_dict", ckpt)
1226
+ else:
1227
+ raise FileNotFoundError(f"LoRA checkpoint not found. Expected either {safetensors_file} or {ckpt_file}")
1228
+
1229
+ # Build param mapping (handle torch.compile's _orig_mod prefix)
1230
+ model_params = dict(self.named_parameters())
1231
+ key_mapping = {k.replace("._orig_mod.", "."): k for k in model_params if "._orig_mod." in k}
1232
+
1233
+ loaded_keys, skipped_keys = [], []
1234
+ for key, value in state_dict.items():
1235
+ target_key = key if key in model_params else key_mapping.get(key)
1236
+ if target_key:
1237
+ model_params[target_key].data.copy_(value.to(device))
1238
+ loaded_keys.append(key)
1239
+ else:
1240
+ skipped_keys.append(key)
1241
+
1242
+ return loaded_keys, skipped_keys
1243
+
1244
+ def set_lora_enabled(self, enabled: bool):
1245
+ """Enable/disable all LoRA layers."""
1246
+ for module in self._iter_lora_modules():
1247
+ module.set_enabled(enabled)
1248
+
1249
+ def reset_lora_weights(self):
1250
+ """Reset all LoRA weights (A: kaiming, B: zeros), effectively unloading LoRA."""
1251
+ for module in self._iter_lora_modules():
1252
+ module.reset_lora_parameters()
1253
+
1254
+ def get_lora_state_dict(self) -> dict:
1255
+ """Get all LoRA parameters (lora_A/lora_B)."""
1256
+ return {name: param.data.clone() for name, param in self.named_parameters() if "lora_" in name}
bluemagpie/_vendor/voxcpm/modules/__init__.py ADDED
File without changes
bluemagpie/_vendor/voxcpm/modules/audiovae/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .audio_vae import AudioVAE, AudioVAEConfig
2
+ from .audio_vae_v2 import AudioVAE as AudioVAEV2, AudioVAEConfig as AudioVAEConfigV2
bluemagpie/_vendor/voxcpm/modules/audiovae/audio_vae.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from typing import List
3
+
4
+ import numpy as np
5
+ import torch
6
+ from torch import nn
7
+ import torch.nn.functional as F
8
+ from torch.nn.utils import weight_norm
9
+ from pydantic import BaseModel
10
+
11
+
12
+ def WNConv1d(*args, **kwargs):
13
+ return weight_norm(nn.Conv1d(*args, **kwargs))
14
+
15
+
16
+ def WNConvTranspose1d(*args, **kwargs):
17
+ return weight_norm(nn.ConvTranspose1d(*args, **kwargs))
18
+
19
+
20
+ class CausalConv1d(nn.Conv1d):
21
+ def __init__(self, *args, padding: int = 0, **kwargs):
22
+ super().__init__(*args, **kwargs)
23
+ self.__padding = padding
24
+
25
+ def forward(self, x):
26
+ x_pad = F.pad(x, (self.__padding * 2, 0))
27
+ return super().forward(x_pad)
28
+
29
+
30
+ class CausalTransposeConv1d(nn.ConvTranspose1d):
31
+ def __init__(self, *args, padding: int = 0, output_padding: int = 0, **kwargs):
32
+ super().__init__(*args, **kwargs)
33
+ self.__padding = padding
34
+ self.__output_padding = output_padding
35
+
36
+ def forward(self, x):
37
+ return super().forward(x)[..., : -(self.__padding * 2 - self.__output_padding)]
38
+
39
+
40
+ def WNCausalConv1d(*args, **kwargs):
41
+ return weight_norm(CausalConv1d(*args, **kwargs))
42
+
43
+
44
+ def WNCausalTransposeConv1d(*args, **kwargs):
45
+ return weight_norm(CausalTransposeConv1d(*args, **kwargs))
46
+
47
+
48
+ # Scripting this brings model speed up 1.4x
49
+ @torch.jit.script
50
+ def snake(x, alpha):
51
+ shape = x.shape
52
+ x = x.reshape(shape[0], shape[1], -1)
53
+ x = x + (alpha + 1e-9).reciprocal() * torch.sin(alpha * x).pow(2)
54
+ x = x.reshape(shape)
55
+ return x
56
+
57
+
58
+ class Snake1d(nn.Module):
59
+ def __init__(self, channels):
60
+ super().__init__()
61
+ self.alpha = nn.Parameter(torch.ones(1, channels, 1))
62
+
63
+ def forward(self, x):
64
+ return snake(x, self.alpha)
65
+
66
+
67
+ def init_weights(m):
68
+ if isinstance(m, nn.Conv1d):
69
+ nn.init.trunc_normal_(m.weight, std=0.02)
70
+ if m.bias is not None:
71
+ nn.init.constant_(m.bias, 0)
72
+
73
+
74
+ class CausalResidualUnit(nn.Module):
75
+ def __init__(self, dim: int = 16, dilation: int = 1, kernel: int = 7, groups: int = 1):
76
+ super().__init__()
77
+ pad = ((7 - 1) * dilation) // 2
78
+ self.block = nn.Sequential(
79
+ Snake1d(dim),
80
+ WNCausalConv1d(
81
+ dim,
82
+ dim,
83
+ kernel_size=kernel,
84
+ dilation=dilation,
85
+ padding=pad,
86
+ groups=groups,
87
+ ),
88
+ Snake1d(dim),
89
+ WNCausalConv1d(dim, dim, kernel_size=1),
90
+ )
91
+
92
+ def forward(self, x):
93
+ y = self.block(x)
94
+ pad = (x.shape[-1] - y.shape[-1]) // 2
95
+ assert pad == 0
96
+ if pad > 0:
97
+ x = x[..., pad:-pad]
98
+ return x + y
99
+
100
+
101
+ class CausalEncoderBlock(nn.Module):
102
+ def __init__(self, output_dim: int = 16, input_dim=None, stride: int = 1, groups=1):
103
+ super().__init__()
104
+ input_dim = input_dim or output_dim // 2
105
+ self.block = nn.Sequential(
106
+ CausalResidualUnit(input_dim, dilation=1, groups=groups),
107
+ CausalResidualUnit(input_dim, dilation=3, groups=groups),
108
+ CausalResidualUnit(input_dim, dilation=9, groups=groups),
109
+ Snake1d(input_dim),
110
+ WNCausalConv1d(
111
+ input_dim,
112
+ output_dim,
113
+ kernel_size=2 * stride,
114
+ stride=stride,
115
+ padding=math.ceil(stride / 2),
116
+ ),
117
+ )
118
+
119
+ def forward(self, x):
120
+ return self.block(x)
121
+
122
+
123
+ class CausalEncoder(nn.Module):
124
+ def __init__(
125
+ self,
126
+ d_model: int = 64,
127
+ latent_dim: int = 32,
128
+ strides: list = [2, 4, 8, 8],
129
+ depthwise: bool = False,
130
+ ):
131
+ super().__init__()
132
+ # Create first convolution
133
+ self.block = [WNCausalConv1d(1, d_model, kernel_size=7, padding=3)]
134
+
135
+ # Create EncoderBlocks that double channels as they downsample by `stride`
136
+ for stride in strides:
137
+ d_model *= 2
138
+ groups = d_model // 2 if depthwise else 1
139
+ self.block += [CausalEncoderBlock(output_dim=d_model, stride=stride, groups=groups)]
140
+
141
+ groups = d_model if depthwise else 1
142
+
143
+ # Create two convolution, for mu and logvar
144
+ self.fc_mu = WNCausalConv1d(d_model, latent_dim, kernel_size=3, padding=1)
145
+ self.fc_logvar = WNCausalConv1d(d_model, latent_dim, kernel_size=3, padding=1)
146
+
147
+ # Wrap black into nn.Sequential
148
+ self.block = nn.Sequential(*self.block)
149
+ self.enc_dim = d_model
150
+
151
+ def forward(self, x):
152
+ hidden_state = self.block(x)
153
+ return {
154
+ "hidden_state": hidden_state,
155
+ "mu": self.fc_mu(hidden_state),
156
+ "logvar": self.fc_logvar(hidden_state),
157
+ }
158
+
159
+
160
+ class NoiseBlock(nn.Module):
161
+ def __init__(self, dim):
162
+ super().__init__()
163
+ self.linear = WNCausalConv1d(dim, dim, kernel_size=1, bias=False)
164
+
165
+ def forward(self, x):
166
+ B, C, T = x.shape
167
+ noise = torch.randn((B, 1, T), device=x.device, dtype=x.dtype)
168
+ h = self.linear(x)
169
+ n = noise * h
170
+ x = x + n
171
+ return x
172
+
173
+
174
+ class CausalDecoderBlock(nn.Module):
175
+ def __init__(
176
+ self,
177
+ input_dim: int = 16,
178
+ output_dim: int = 8,
179
+ stride: int = 1,
180
+ groups=1,
181
+ use_noise_block: bool = False,
182
+ ):
183
+ super().__init__()
184
+ layers = [
185
+ Snake1d(input_dim),
186
+ WNCausalTransposeConv1d(
187
+ input_dim,
188
+ output_dim,
189
+ kernel_size=2 * stride,
190
+ stride=stride,
191
+ padding=math.ceil(stride / 2),
192
+ output_padding=stride % 2,
193
+ ),
194
+ ]
195
+ if use_noise_block:
196
+ layers.append(NoiseBlock(output_dim))
197
+ layers.extend(
198
+ [
199
+ CausalResidualUnit(output_dim, dilation=1, groups=groups),
200
+ CausalResidualUnit(output_dim, dilation=3, groups=groups),
201
+ CausalResidualUnit(output_dim, dilation=9, groups=groups),
202
+ ]
203
+ )
204
+ self.block = nn.Sequential(*layers)
205
+
206
+ def forward(self, x):
207
+ return self.block(x)
208
+
209
+
210
+ class TransposeLastTwoDim(torch.nn.Module):
211
+ def forward(self, x):
212
+ return torch.transpose(x, -1, -2)
213
+
214
+
215
+ class CausalDecoder(nn.Module):
216
+ def __init__(
217
+ self,
218
+ input_channel,
219
+ channels,
220
+ rates,
221
+ depthwise: bool = False,
222
+ d_out: int = 1,
223
+ use_noise_block: bool = False,
224
+ ):
225
+ super().__init__()
226
+
227
+ # Add first conv layer
228
+ if depthwise:
229
+ layers = [
230
+ WNCausalConv1d(
231
+ input_channel,
232
+ input_channel,
233
+ kernel_size=7,
234
+ padding=3,
235
+ groups=input_channel,
236
+ ),
237
+ WNCausalConv1d(input_channel, channels, kernel_size=1),
238
+ ]
239
+ else:
240
+ layers = [WNCausalConv1d(input_channel, channels, kernel_size=7, padding=3)]
241
+
242
+ # Add upsampling + MRF blocks
243
+ for i, stride in enumerate(rates):
244
+ input_dim = channels // 2**i
245
+ output_dim = channels // 2 ** (i + 1)
246
+ groups = output_dim if depthwise else 1
247
+ layers += [
248
+ CausalDecoderBlock(
249
+ input_dim,
250
+ output_dim,
251
+ stride,
252
+ groups=groups,
253
+ use_noise_block=use_noise_block,
254
+ )
255
+ ]
256
+
257
+ # Add final conv layer
258
+ layers += [
259
+ Snake1d(output_dim),
260
+ WNCausalConv1d(output_dim, d_out, kernel_size=7, padding=3),
261
+ nn.Tanh(),
262
+ ]
263
+
264
+ self.model = nn.Sequential(*layers)
265
+
266
+ def forward(self, x):
267
+ return self.model(x)
268
+
269
+
270
+ class AudioVAEConfig(BaseModel):
271
+ encoder_dim: int = 128
272
+ encoder_rates: List[int] = [2, 5, 8, 8]
273
+ latent_dim: int = 64
274
+ decoder_dim: int = 1536
275
+ decoder_rates: List[int] = [8, 8, 5, 2]
276
+ depthwise: bool = True
277
+ sample_rate: int = 16000
278
+ use_noise_block: bool = False
279
+
280
+
281
+ class AudioVAE(nn.Module):
282
+ """
283
+ Args:
284
+ """
285
+
286
+ def __init__(
287
+ self,
288
+ config: AudioVAEConfig = None,
289
+ ):
290
+ # 如果没有传入config,使用默认配置
291
+ if config is None:
292
+ config = AudioVAEConfig()
293
+
294
+ super().__init__()
295
+
296
+ encoder_dim = config.encoder_dim
297
+ encoder_rates = config.encoder_rates
298
+ latent_dim = config.latent_dim
299
+ decoder_dim = config.decoder_dim
300
+ decoder_rates = config.decoder_rates
301
+ depthwise = config.depthwise
302
+ sample_rate = config.sample_rate
303
+ use_noise_block = config.use_noise_block
304
+
305
+ self.encoder_dim = encoder_dim
306
+ self.encoder_rates = encoder_rates
307
+ self.decoder_dim = decoder_dim
308
+ self.decoder_rates = decoder_rates
309
+ self.depthwise = depthwise
310
+
311
+ self.use_noise_block = use_noise_block
312
+
313
+ if latent_dim is None:
314
+ latent_dim = encoder_dim * (2 ** len(encoder_rates))
315
+
316
+ self.latent_dim = latent_dim
317
+ self.hop_length = np.prod(encoder_rates)
318
+ self.encoder = CausalEncoder(
319
+ encoder_dim,
320
+ latent_dim,
321
+ encoder_rates,
322
+ depthwise=depthwise,
323
+ )
324
+
325
+ self.decoder = CausalDecoder(
326
+ latent_dim,
327
+ decoder_dim,
328
+ decoder_rates,
329
+ depthwise=depthwise,
330
+ use_noise_block=use_noise_block,
331
+ )
332
+ self.sample_rate = sample_rate
333
+ self.chunk_size = math.prod(encoder_rates)
334
+
335
+ def preprocess(self, audio_data, sample_rate):
336
+ if sample_rate is None:
337
+ sample_rate = self.sample_rate
338
+ assert sample_rate == self.sample_rate
339
+ pad_to = self.hop_length
340
+ length = audio_data.shape[-1]
341
+ right_pad = math.ceil(length / pad_to) * pad_to - length
342
+ audio_data = nn.functional.pad(audio_data, (0, right_pad))
343
+
344
+ return audio_data
345
+
346
+ def decode(self, z: torch.Tensor):
347
+ """Decode given latent codes and return audio data
348
+
349
+ Parameters
350
+ ----------
351
+ z : Tensor[B x D x T]
352
+ Quantized continuous representation of input
353
+ length : int, optional
354
+ Number of samples in output audio, by default None
355
+
356
+ Returns
357
+ -------
358
+ dict
359
+ A dictionary with the following keys:
360
+ "audio" : Tensor[B x 1 x length]
361
+ Decoded audio data.
362
+ """
363
+ return self.decoder(z)
364
+
365
+ def encode(self, audio_data: torch.Tensor, sample_rate: int):
366
+ """
367
+ Args:
368
+ audio_data: Tensor[B x 1 x T]
369
+ sample_rate: int
370
+ Returns:
371
+ z: Tensor[B x D x T]
372
+ """
373
+ if audio_data.ndim == 2:
374
+ audio_data = audio_data.unsqueeze(1)
375
+
376
+ audio_data = self.preprocess(audio_data, sample_rate)
377
+ return self.encoder(audio_data)["mu"]
bluemagpie/_vendor/voxcpm/modules/audiovae/audio_vae_v2.py ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from typing import List, Optional
3
+
4
+ import numpy as np
5
+ import torch
6
+ from torch import nn
7
+ import torch.nn.functional as F
8
+ from torch.nn.utils import weight_norm
9
+ from pydantic import BaseModel
10
+
11
+
12
+ def WNConv1d(*args, **kwargs):
13
+ return weight_norm(nn.Conv1d(*args, **kwargs))
14
+
15
+
16
+ def WNConvTranspose1d(*args, **kwargs):
17
+ return weight_norm(nn.ConvTranspose1d(*args, **kwargs))
18
+
19
+
20
+ class CausalConv1d(nn.Conv1d):
21
+ def __init__(self, *args, padding: int = 0, output_padding: int = 0, **kwargs):
22
+ super().__init__(*args, **kwargs)
23
+ self.__padding = padding
24
+ self.__output_padding = output_padding
25
+
26
+ def forward(self, x):
27
+ x_pad = F.pad(x, (self.__padding * 2 - self.__output_padding, 0))
28
+ return super().forward(x_pad)
29
+
30
+
31
+ class CausalTransposeConv1d(nn.ConvTranspose1d):
32
+ def __init__(self, *args, padding: int = 0, output_padding: int = 0, **kwargs):
33
+ super().__init__(*args, **kwargs)
34
+ self.__padding = padding
35
+ self.__output_padding = output_padding
36
+
37
+ def forward(self, x):
38
+ return super().forward(x)[..., : -(self.__padding * 2 - self.__output_padding)]
39
+
40
+
41
+ def WNCausalConv1d(*args, **kwargs):
42
+ return weight_norm(CausalConv1d(*args, **kwargs))
43
+
44
+
45
+ def WNCausalTransposeConv1d(*args, **kwargs):
46
+ return weight_norm(CausalTransposeConv1d(*args, **kwargs))
47
+
48
+
49
+ # Scripting this brings model speed up 1.4x
50
+ @torch.jit.script
51
+ def snake(x, alpha):
52
+ shape = x.shape
53
+ x = x.reshape(shape[0], shape[1], -1)
54
+ x = x + (alpha + 1e-9).reciprocal() * torch.sin(alpha * x).pow(2)
55
+ x = x.reshape(shape)
56
+ return x
57
+
58
+
59
+ class Snake1d(nn.Module):
60
+ def __init__(self, channels):
61
+ super().__init__()
62
+ self.alpha = nn.Parameter(torch.ones(1, channels, 1))
63
+
64
+ def forward(self, x):
65
+ return snake(x, self.alpha)
66
+
67
+
68
+ def init_weights(m):
69
+ if isinstance(m, nn.Conv1d):
70
+ nn.init.trunc_normal_(m.weight, std=0.02)
71
+ if m.bias is not None:
72
+ nn.init.constant_(m.bias, 0)
73
+
74
+
75
+ class CausalResidualUnit(nn.Module):
76
+ def __init__(self, dim: int = 16, dilation: int = 1, kernel: int = 7, groups: int = 1):
77
+ super().__init__()
78
+ pad = ((7 - 1) * dilation) // 2
79
+ self.block = nn.Sequential(
80
+ Snake1d(dim),
81
+ WNCausalConv1d(
82
+ dim,
83
+ dim,
84
+ kernel_size=kernel,
85
+ dilation=dilation,
86
+ padding=pad,
87
+ groups=groups,
88
+ ),
89
+ Snake1d(dim),
90
+ WNCausalConv1d(dim, dim, kernel_size=1),
91
+ )
92
+
93
+ def forward(self, x):
94
+ y = self.block(x)
95
+ pad = (x.shape[-1] - y.shape[-1]) // 2
96
+ assert pad == 0
97
+ if pad > 0:
98
+ x = x[..., pad:-pad]
99
+ return x + y
100
+
101
+
102
+ class CausalEncoderBlock(nn.Module):
103
+ def __init__(self, output_dim: int = 16, input_dim=None, stride: int = 1, groups=1):
104
+ super().__init__()
105
+ input_dim = input_dim or output_dim // 2
106
+ self.block = nn.Sequential(
107
+ CausalResidualUnit(input_dim, dilation=1, groups=groups),
108
+ CausalResidualUnit(input_dim, dilation=3, groups=groups),
109
+ CausalResidualUnit(input_dim, dilation=9, groups=groups),
110
+ Snake1d(input_dim),
111
+ WNCausalConv1d(
112
+ input_dim,
113
+ output_dim,
114
+ kernel_size=2 * stride,
115
+ stride=stride,
116
+ padding=math.ceil(stride / 2),
117
+ output_padding=stride % 2,
118
+ ),
119
+ )
120
+
121
+ def forward(self, x):
122
+ return self.block(x)
123
+
124
+
125
+ class CausalEncoder(nn.Module):
126
+ def __init__(
127
+ self,
128
+ d_model: int = 64,
129
+ latent_dim: int = 32,
130
+ strides: list = [2, 4, 8, 8],
131
+ depthwise: bool = False,
132
+ ):
133
+ super().__init__()
134
+ # Create first convolution
135
+ self.block = [WNCausalConv1d(1, d_model, kernel_size=7, padding=3)]
136
+
137
+ # Create EncoderBlocks that double channels as they downsample by `stride`
138
+ for stride in strides:
139
+ d_model *= 2
140
+ groups = d_model // 2 if depthwise else 1
141
+ self.block += [CausalEncoderBlock(output_dim=d_model, stride=stride, groups=groups)]
142
+
143
+ groups = d_model if depthwise else 1
144
+
145
+ # Create two convolution, for mu and logvar
146
+ self.fc_mu = WNCausalConv1d(d_model, latent_dim, kernel_size=3, padding=1)
147
+ self.fc_logvar = WNCausalConv1d(d_model, latent_dim, kernel_size=3, padding=1)
148
+
149
+ # Wrap black into nn.Sequential
150
+ self.block = nn.Sequential(*self.block)
151
+ self.enc_dim = d_model
152
+
153
+ def forward(self, x):
154
+ hidden_state = self.block(x)
155
+ return {
156
+ "hidden_state": hidden_state,
157
+ "mu": self.fc_mu(hidden_state),
158
+ "logvar": self.fc_logvar(hidden_state),
159
+ }
160
+
161
+
162
+ class NoiseBlock(nn.Module):
163
+ def __init__(self, dim):
164
+ super().__init__()
165
+ self.linear = WNCausalConv1d(dim, dim, kernel_size=1, bias=False)
166
+
167
+ def forward(self, x):
168
+ B, C, T = x.shape
169
+ noise = torch.randn((B, 1, T), device=x.device, dtype=x.dtype)
170
+ h = self.linear(x)
171
+ n = noise * h
172
+ x = x + n
173
+ return x
174
+
175
+
176
+ class CausalDecoderBlock(nn.Module):
177
+ def __init__(
178
+ self,
179
+ input_dim: int = 16,
180
+ output_dim: int = 8,
181
+ stride: int = 1,
182
+ groups=1,
183
+ use_noise_block: bool = False,
184
+ ):
185
+ super().__init__()
186
+ layers = [
187
+ Snake1d(input_dim),
188
+ WNCausalTransposeConv1d(
189
+ input_dim,
190
+ output_dim,
191
+ kernel_size=2 * stride,
192
+ stride=stride,
193
+ padding=math.ceil(stride / 2),
194
+ output_padding=stride % 2,
195
+ ),
196
+ ]
197
+ if use_noise_block:
198
+ layers.append(NoiseBlock(output_dim))
199
+ layers.extend(
200
+ [
201
+ CausalResidualUnit(output_dim, dilation=1, groups=groups),
202
+ CausalResidualUnit(output_dim, dilation=3, groups=groups),
203
+ CausalResidualUnit(output_dim, dilation=9, groups=groups),
204
+ ]
205
+ )
206
+ self.block = nn.Sequential(*layers)
207
+ self.input_channels = input_dim
208
+
209
+ def forward(self, x):
210
+ return self.block(x)
211
+
212
+
213
+ class TransposeLastTwoDim(torch.nn.Module):
214
+ def forward(self, x):
215
+ return torch.transpose(x, -1, -2)
216
+
217
+
218
+ class SampleRateConditionLayer(nn.Module):
219
+ def __init__(
220
+ self,
221
+ input_dim: int,
222
+ sr_bin_buckets: int = None,
223
+ cond_type: str = "scale_bias",
224
+ cond_dim: int = 128,
225
+ out_layer: bool = False,
226
+ ):
227
+ super().__init__()
228
+
229
+ self.cond_type, out_layer_in_dim = cond_type, input_dim
230
+
231
+ if cond_type == "scale_bias":
232
+ self.scale_embed = nn.Embedding(sr_bin_buckets, input_dim)
233
+ self.bias_embed = nn.Embedding(sr_bin_buckets, input_dim)
234
+ nn.init.ones_(self.scale_embed.weight)
235
+ nn.init.zeros_(self.bias_embed.weight)
236
+ elif cond_type == "scale_bias_init":
237
+ self.scale_embed = nn.Embedding(sr_bin_buckets, input_dim)
238
+ self.bias_embed = nn.Embedding(sr_bin_buckets, input_dim)
239
+ nn.init.normal_(self.scale_embed.weight, mean=1)
240
+ nn.init.normal_(self.bias_embed.weight)
241
+ elif cond_type == "add":
242
+ self.cond_embed = nn.Embedding(sr_bin_buckets, input_dim)
243
+ nn.init.normal_(self.cond_embed.weight)
244
+ elif cond_type == "concat":
245
+ self.cond_embed = nn.Embedding(sr_bin_buckets, cond_dim)
246
+ assert out_layer, "out_layer must be True for concat cond_type"
247
+ out_layer_in_dim = input_dim + cond_dim
248
+ else:
249
+ raise ValueError(f"Invalid cond_type: {cond_type}")
250
+
251
+ if out_layer:
252
+ self.out_layer = nn.Sequential(
253
+ Snake1d(out_layer_in_dim),
254
+ WNCausalConv1d(out_layer_in_dim, input_dim, kernel_size=1),
255
+ )
256
+ else:
257
+ self.out_layer = nn.Identity()
258
+
259
+ def forward(self, x, sr_cond):
260
+ if self.cond_type == "scale_bias" or self.cond_type == "scale_bias_init":
261
+ x = x * self.scale_embed(sr_cond).unsqueeze(-1) + self.bias_embed(sr_cond).unsqueeze(-1)
262
+ elif self.cond_type == "add":
263
+ x = x + self.cond_embed(sr_cond).unsqueeze(-1)
264
+ elif self.cond_type == "concat":
265
+ x = torch.cat([x, self.cond_embed(sr_cond).unsqueeze(-1).repeat(1, 1, x.shape[-1])], dim=1)
266
+
267
+ return self.out_layer(x)
268
+
269
+
270
+ class CausalDecoder(nn.Module):
271
+ def __init__(
272
+ self,
273
+ input_channel,
274
+ channels,
275
+ rates,
276
+ depthwise: bool = False,
277
+ d_out: int = 1,
278
+ use_noise_block: bool = False,
279
+ sr_bin_boundaries: List[int] = None,
280
+ cond_type: str = "scale_bias",
281
+ cond_dim: int = 128,
282
+ cond_out_layer: bool = False,
283
+ ):
284
+ super().__init__()
285
+
286
+ # Add first conv layer
287
+ if depthwise:
288
+ layers = [
289
+ WNCausalConv1d(input_channel, input_channel, kernel_size=7, padding=3, groups=input_channel),
290
+ WNCausalConv1d(input_channel, channels, kernel_size=1),
291
+ ]
292
+ else:
293
+ layers = [WNCausalConv1d(input_channel, channels, kernel_size=7, padding=3)]
294
+
295
+ # Add upsampling + MRF blocks
296
+ for i, stride in enumerate(rates):
297
+ input_dim = channels // 2**i
298
+ output_dim = channels // 2 ** (i + 1)
299
+ groups = output_dim if depthwise else 1
300
+ layers += [
301
+ CausalDecoderBlock(
302
+ input_dim,
303
+ output_dim,
304
+ stride,
305
+ groups=groups,
306
+ use_noise_block=use_noise_block,
307
+ )
308
+ ]
309
+
310
+ # Add final conv layer
311
+ layers += [
312
+ Snake1d(output_dim),
313
+ WNCausalConv1d(output_dim, d_out, kernel_size=7, padding=3),
314
+ nn.Tanh(),
315
+ ]
316
+
317
+ if sr_bin_boundaries is None:
318
+ self.model = nn.Sequential(*layers)
319
+ self.sr_bin_boundaries = None
320
+ else:
321
+ self.model = nn.ModuleList(layers)
322
+
323
+ self.register_buffer("sr_bin_boundaries", torch.tensor(sr_bin_boundaries, dtype=torch.int32))
324
+ self.sr_bin_buckets = len(sr_bin_boundaries) + 1
325
+
326
+ cond_layers = []
327
+ for layer in self.model:
328
+ if layer.__class__.__name__ == "CausalDecoderBlock":
329
+ cond_layers.append(
330
+ SampleRateConditionLayer(
331
+ input_dim=layer.input_channels,
332
+ sr_bin_buckets=self.sr_bin_buckets,
333
+ cond_type=cond_type,
334
+ cond_dim=cond_dim,
335
+ out_layer=cond_out_layer,
336
+ )
337
+ )
338
+ else:
339
+ cond_layers.append(None)
340
+ self.sr_cond_model = nn.ModuleList(cond_layers)
341
+
342
+ def get_sr_idx(self, sr):
343
+ return torch.bucketize(sr, self.sr_bin_boundaries)
344
+
345
+ def forward(self, x, sr_cond=None):
346
+ if self.sr_bin_boundaries is not None:
347
+ # assert sr_cond is not None
348
+ sr_cond = self.get_sr_idx(sr_cond)
349
+
350
+ for layer, sr_cond_layer in zip(self.model, self.sr_cond_model):
351
+ if sr_cond_layer is not None:
352
+ x = sr_cond_layer(x, sr_cond)
353
+ x = layer(x)
354
+ return x
355
+ else:
356
+ return self.model(x)
357
+
358
+
359
+ class AudioVAEConfig(BaseModel):
360
+ encoder_dim: int = 128
361
+ encoder_rates: List[int] = [2, 5, 8, 8]
362
+ latent_dim: int = 64
363
+ decoder_dim: int = 2048
364
+ decoder_rates: List[int] = [8, 6, 5, 2, 2, 2]
365
+ depthwise: bool = True
366
+ sample_rate: int = 16000
367
+ out_sample_rate: int = 48000
368
+ use_noise_block: bool = False
369
+ sr_bin_boundaries: Optional[List[int]] = [20000, 30000, 40000]
370
+ cond_type: str = "scale_bias"
371
+ cond_dim: int = 128
372
+ cond_out_layer: bool = False
373
+
374
+
375
+ class AudioVAE(nn.Module):
376
+ """
377
+ Args:
378
+ """
379
+
380
+ def __init__(
381
+ self,
382
+ config: AudioVAEConfig = None,
383
+ ):
384
+ # 如果没有传入config,使用默认配置
385
+ if config is None:
386
+ config = AudioVAEConfig()
387
+
388
+ super().__init__()
389
+
390
+ encoder_dim = config.encoder_dim
391
+ encoder_rates = config.encoder_rates
392
+ latent_dim = config.latent_dim
393
+ decoder_dim = config.decoder_dim
394
+ decoder_rates = config.decoder_rates
395
+ depthwise = config.depthwise
396
+ sample_rate = config.sample_rate
397
+ out_sample_rate = config.out_sample_rate
398
+ use_noise_block = config.use_noise_block
399
+ sr_bin_boundaries = config.sr_bin_boundaries
400
+ cond_type = config.cond_type
401
+ cond_dim = config.cond_dim
402
+ cond_out_layer = config.cond_out_layer
403
+
404
+ self.encoder_dim = encoder_dim
405
+ self.encoder_rates = encoder_rates
406
+ self.decoder_dim = decoder_dim
407
+ self.decoder_rates = decoder_rates
408
+ self.depthwise = depthwise
409
+
410
+ self.use_noise_block = use_noise_block
411
+
412
+ if latent_dim is None:
413
+ latent_dim = encoder_dim * (2 ** len(encoder_rates))
414
+
415
+ self.latent_dim = latent_dim
416
+ self.hop_length = np.prod(encoder_rates)
417
+ self.encoder = CausalEncoder(
418
+ encoder_dim,
419
+ latent_dim,
420
+ encoder_rates,
421
+ depthwise=depthwise,
422
+ )
423
+
424
+ self.decoder = CausalDecoder(
425
+ latent_dim,
426
+ decoder_dim,
427
+ decoder_rates,
428
+ depthwise=depthwise,
429
+ use_noise_block=use_noise_block,
430
+ sr_bin_boundaries=sr_bin_boundaries,
431
+ cond_type=cond_type,
432
+ cond_dim=cond_dim,
433
+ cond_out_layer=cond_out_layer,
434
+ )
435
+ self.sample_rate = sample_rate
436
+ self.out_sample_rate = out_sample_rate
437
+ self.sr_bin_boundaries = sr_bin_boundaries
438
+ self.chunk_size = math.prod(encoder_rates)
439
+ self.decode_chunk_size = math.prod(decoder_rates)
440
+
441
+ def preprocess(self, audio_data, sample_rate):
442
+ if sample_rate is None:
443
+ sample_rate = self.sample_rate
444
+ assert sample_rate == self.sample_rate
445
+ pad_to = self.hop_length
446
+ length = audio_data.shape[-1]
447
+ right_pad = math.ceil(length / pad_to) * pad_to - length
448
+ audio_data = nn.functional.pad(audio_data, (0, right_pad))
449
+
450
+ return audio_data
451
+
452
+ def decode(self, z: torch.Tensor, sr_cond: torch.Tensor = None):
453
+ """Decode given latent codes and return audio data
454
+
455
+ Parameters
456
+ ----------
457
+ z : Tensor[B x D x T]
458
+ Quantized continuous representation of input
459
+ length : int, optional
460
+ Number of samples in output audio, by default None
461
+
462
+ Returns
463
+ -------
464
+ dict
465
+ A dictionary with the following keys:
466
+ "audio" : Tensor[B x 1 x length]
467
+ Decoded audio data.
468
+ """
469
+ if self.sr_bin_boundaries is not None:
470
+ # use default output sample rate
471
+ if sr_cond is None:
472
+ sr_cond = torch.tensor([self.out_sample_rate], device=z.device, dtype=torch.int32)
473
+ return self.decoder(z, sr_cond)
474
+
475
+ def streaming_decode(self):
476
+ """Return a ``StreamingVAEDecoder`` context manager for stateful
477
+ chunk-by-chunk decoding. Each call to ``decode_chunk`` processes only
478
+ the new latent patch and carries causal-conv state internally, avoiding
479
+ the redundant overlap decode used previously.
480
+
481
+ Usage::
482
+
483
+ with vae.streaming_decode() as dec:
484
+ for patch in patches:
485
+ audio_chunk = dec.decode_chunk(patch)
486
+ """
487
+ return StreamingVAEDecoder(self)
488
+
489
+ def encode(self, audio_data: torch.Tensor, sample_rate: int):
490
+ """
491
+ Args:
492
+ audio_data: Tensor[B x 1 x T]
493
+ sample_rate: int
494
+ Returns:
495
+ z: Tensor[B x D x T]
496
+ """
497
+ if audio_data.ndim == 2:
498
+ audio_data = audio_data.unsqueeze(1)
499
+
500
+ audio_data = self.preprocess(audio_data, sample_rate)
501
+ return self.encoder(audio_data)["mu"]
502
+
503
+
504
+ class StreamingVAEDecoder:
505
+ """Stateful streaming wrapper for :class:`AudioVAE`.
506
+
507
+ Carries causal-convolution padding buffers between calls so that each
508
+ ``decode_chunk`` processes only the new latent patch — no overlap needed.
509
+ """
510
+
511
+ def __init__(self, vae: AudioVAE):
512
+ self._vae = vae
513
+ self._states: dict = {}
514
+ self._originals: list = []
515
+
516
+ # -- context manager --------------------------------------------------
517
+ def __enter__(self):
518
+ self._states.clear()
519
+ self._install()
520
+ return self
521
+
522
+ def __exit__(self, *exc):
523
+ self._restore()
524
+ self._states.clear()
525
+
526
+ # -- public API --------------------------------------------------------
527
+ def decode_chunk(self, z_chunk: torch.Tensor) -> torch.Tensor:
528
+ """Decode a single latent chunk and return the audio waveform."""
529
+ return self._vae.decode(z_chunk)
530
+
531
+ # -- internals ---------------------------------------------------------
532
+ def _install(self):
533
+ for name, mod in self._vae.decoder.named_modules():
534
+ if isinstance(mod, CausalConv1d):
535
+ pad = mod._CausalConv1d__padding * 2 - mod._CausalConv1d__output_padding
536
+ if pad > 0:
537
+ self._patch_causal_conv(mod, pad)
538
+ elif isinstance(mod, CausalTransposeConv1d):
539
+ trim = mod._CausalTransposeConv1d__padding * 2 - mod._CausalTransposeConv1d__output_padding
540
+ ctx = (mod.kernel_size[0] - 1) // mod.stride[0]
541
+ if ctx > 0:
542
+ self._patch_transpose_conv(mod, ctx, trim)
543
+
544
+ def _patch_causal_conv(self, mod, pad_size):
545
+ states = self._states
546
+ key = id(mod)
547
+ orig = mod.forward
548
+
549
+ def fwd(x, _k=key, _p=pad_size, _m=mod):
550
+ x_pad = torch.cat([states[_k], x], dim=-1) if _k in states else F.pad(x, (_p, 0))
551
+ if x.shape[-1] >= _p:
552
+ states[_k] = x[:, :, -_p:].detach()
553
+ else:
554
+ prev = states.get(_k, torch.zeros(x.shape[0], x.shape[1], _p,
555
+ device=x.device, dtype=x.dtype))
556
+ states[_k] = torch.cat([prev, x], dim=-1)[:, :, -_p:].detach()
557
+ return nn.Conv1d.forward(_m, x_pad)
558
+
559
+ mod.forward = fwd
560
+ self._originals.append((mod, orig))
561
+
562
+ def _patch_transpose_conv(self, mod, ctx, trim):
563
+ states = self._states
564
+ key = id(mod)
565
+ orig = mod.forward
566
+
567
+ def fwd(x, _k=key, _c=ctx, _t=trim, _m=mod):
568
+ x_full = torch.cat([states[_k], x], dim=-1) if _k in states else F.pad(x, (_c, 0))
569
+ states[_k] = x[:, :, -_c:].detach()
570
+ out = nn.ConvTranspose1d.forward(_m, x_full)
571
+ left = _c * _m.stride[0]
572
+ return out[..., left:-_t] if _t > 0 else out[..., left:]
573
+
574
+ mod.forward = fwd
575
+ self._originals.append((mod, orig))
576
+
577
+ def _restore(self):
578
+ for mod, orig in self._originals:
579
+ mod.forward = orig
580
+ self._originals.clear()
bluemagpie/_vendor/voxcpm/modules/layers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .scalar_quantization_layer import ScalarQuantizationLayer
bluemagpie/_vendor/voxcpm/modules/layers/lora.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from typing import Optional
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+ import torch.nn.functional as F
7
+
8
+
9
+ class LoRALinear(nn.Module):
10
+ """
11
+ LoRA 线性层:直接持有 weight/bias,保持与 nn.Linear 相同的 state_dict key 结构。
12
+
13
+ state_dict 结构:
14
+ - weight: 原始权重(与 nn.Linear 一致)
15
+ - bias: 原始偏置(与 nn.Linear 一致)
16
+ - lora_A: LoRA 低秩矩阵 A
17
+ - lora_B: LoRA 低秩矩阵 B
18
+
19
+ 这样设计的好处:加载预训练权重时无需做 key 转换。
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ base: nn.Linear,
25
+ r: int,
26
+ alpha: float = 1.0,
27
+ dropout: float = 0.0,
28
+ ):
29
+ super().__init__()
30
+ assert isinstance(base, nn.Linear), "LoRALinear only supports wrapping nn.Linear."
31
+
32
+ self.in_features = base.in_features
33
+ self.out_features = base.out_features
34
+ self.r = r
35
+ self.alpha = alpha
36
+ self._base_scaling = alpha / r if r > 0 else 0.0
37
+
38
+ # 使用 buffer 存储 scaling,这样修改值不会触发 torch.compile 重编译
39
+ # persistent=False 表示不保存到 state_dict,避免加载时 missing key
40
+ self.register_buffer("scaling", torch.tensor(self._base_scaling), persistent=False)
41
+
42
+ # 直接持有 weight 和 bias(从原始 Linear 转移过来)
43
+ self.weight = base.weight
44
+ self.bias = base.bias # 可能是 None
45
+
46
+ # LoRA 参数
47
+ if r > 0:
48
+ self.lora_A = nn.Parameter(torch.zeros(r, self.in_features))
49
+ self.lora_B = nn.Parameter(torch.zeros(self.out_features, r))
50
+ nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
51
+ nn.init.zeros_(self.lora_B)
52
+ else:
53
+ self.register_parameter("lora_A", None)
54
+ self.register_parameter("lora_B", None)
55
+
56
+ self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
57
+
58
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
59
+ # 基础 Linear 计算
60
+ result = F.linear(x, self.weight, self.bias)
61
+ if self.r <= 0 or self.lora_A is None:
62
+ return result
63
+ # LoRA: result + dropout(x @ A^T @ B^T) * scaling
64
+ lora_out = F.linear(F.linear(x, self.lora_A), self.lora_B)
65
+ return result + self.dropout(lora_out) * self.scaling
66
+
67
+ def reset_lora_parameters(self):
68
+ """重置 LoRA 参数到初始状态"""
69
+ if self.r > 0 and self.lora_A is not None:
70
+ nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
71
+ nn.init.zeros_(self.lora_B)
72
+
73
+ def set_enabled(self, enabled: bool):
74
+ """启用/禁用 LoRA(通过 scaling 控制,兼容 torch.compile)"""
75
+ # 使用 fill_ 原地修改 buffer 值,不会触发重编译
76
+ self.scaling.fill_(self._base_scaling if enabled else 0.0)
77
+
78
+ @property
79
+ def enabled(self) -> bool:
80
+ return self.scaling.item() != 0.0
81
+
82
+
83
+ def _get_parent_module(root: nn.Module, name: str) -> Optional[nn.Module]:
84
+ """
85
+ 根据类似 'layers.0.self_attn.q_proj' 的全名,返回 parent module(即 q_proj 的上一级)。
86
+ """
87
+ parts = name.split(".")
88
+ if len(parts) == 1:
89
+ return root
90
+ parent = root
91
+ for p in parts[:-1]:
92
+ if not hasattr(parent, p):
93
+ return None
94
+ parent = getattr(parent, p)
95
+ return parent
96
+
97
+
98
+ def apply_lora_to_named_linear_modules(
99
+ root: nn.Module,
100
+ *,
101
+ target_submodule_names: list[str],
102
+ r: int,
103
+ alpha: float,
104
+ dropout: float,
105
+ ) -> None:
106
+ """
107
+ 在给定模块及其子模块中,对名字以 target_submodule_names 结尾的 Linear 层注入 LoRA。
108
+
109
+ 例如 target_submodule_names=["q_proj", "v_proj"] 时,
110
+ 会在所有名为 *.q_proj / *.v_proj 的 nn.Linear 上替换为 LoRALinear。
111
+ """
112
+ for full_name, module in list(root.named_modules()):
113
+ if not isinstance(module, nn.Linear):
114
+ continue
115
+ short_name = full_name.split(".")[-1]
116
+ if short_name not in target_submodule_names:
117
+ continue
118
+
119
+ parent = _get_parent_module(root, full_name)
120
+ if parent is None:
121
+ continue
122
+
123
+ # 用 LoRALinear 替换原始 Linear
124
+ lora_layer = LoRALinear(
125
+ base=module,
126
+ r=r,
127
+ alpha=alpha,
128
+ dropout=dropout,
129
+ )
130
+ setattr(parent, short_name, lora_layer)
bluemagpie/_vendor/voxcpm/modules/layers/scalar_quantization_layer.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+
4
+
5
+ class ScalarQuantizationLayer(nn.Module):
6
+ def __init__(self, in_dim, out_dim, latent_dim: int = 64, scale: int = 9):
7
+ super().__init__()
8
+ self.in_dim = in_dim
9
+ self.out_dim = out_dim
10
+ self.latent_dim = latent_dim
11
+ self.scale = scale
12
+
13
+ self.in_proj = nn.Linear(in_dim, latent_dim)
14
+ self.out_proj = nn.Linear(latent_dim, out_dim)
15
+
16
+ def forward(self, hidden):
17
+ hidden = self.in_proj(hidden)
18
+ hidden = torch.tanh(hidden)
19
+
20
+ if self.training:
21
+ quantized = torch.round(hidden * self.scale) / self.scale
22
+ hidden = hidden + (quantized - hidden).detach()
23
+ else:
24
+ hidden = torch.round(hidden * self.scale) / self.scale
25
+
26
+ return self.out_proj(hidden)
bluemagpie/_vendor/voxcpm/modules/locdit/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .unified_cfm import UnifiedCFM, CfmConfig
2
+ from .local_dit import VoxCPMLocDiT
3
+ from .local_dit_v2 import VoxCPMLocDiT as VoxCPMLocDiTV2
bluemagpie/_vendor/voxcpm/modules/locdit/local_dit.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from ..minicpm4 import MiniCPMModel, MiniCPM4Config
3
+ import torch.nn as nn
4
+ import math
5
+
6
+
7
+ class SinusoidalPosEmb(torch.nn.Module):
8
+ def __init__(self, dim):
9
+ super().__init__()
10
+ self.dim = dim
11
+ assert self.dim % 2 == 0, "SinusoidalPosEmb requires dim to be even"
12
+
13
+ def forward(self, x, scale=1000):
14
+ if x.ndim < 1:
15
+ x = x.unsqueeze(0)
16
+ device = x.device
17
+ half_dim = self.dim // 2
18
+ emb = math.log(10000) / (half_dim - 1)
19
+ emb = torch.exp(torch.arange(half_dim, dtype=x.dtype, device=device) * -emb)
20
+ emb = scale * x.unsqueeze(1) * emb.unsqueeze(0)
21
+ emb = torch.cat((emb.sin(), emb.cos()), dim=-1)
22
+ return emb
23
+
24
+
25
+ class TimestepEmbedding(nn.Module):
26
+ def __init__(
27
+ self,
28
+ in_channels: int,
29
+ time_embed_dim: int,
30
+ out_dim: int = None,
31
+ ):
32
+ super().__init__()
33
+
34
+ self.linear_1 = nn.Linear(in_channels, time_embed_dim, bias=True)
35
+ self.act = nn.SiLU()
36
+ if out_dim is not None:
37
+ time_embed_dim_out = out_dim
38
+ else:
39
+ time_embed_dim_out = time_embed_dim
40
+
41
+ self.linear_2 = nn.Linear(time_embed_dim, time_embed_dim_out, bias=True)
42
+
43
+ def forward(self, sample):
44
+ sample = self.linear_1(sample)
45
+ sample = self.act(sample)
46
+ sample = self.linear_2(sample)
47
+ return sample
48
+
49
+
50
+ class VoxCPMLocDiT(nn.Module):
51
+ """
52
+ Diffusion model with a Transformer backbone.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ config: MiniCPM4Config,
58
+ in_channels: int = 64,
59
+ ):
60
+ super().__init__()
61
+ self.in_channels = in_channels
62
+ self.out_channels = in_channels
63
+ self.config = config
64
+
65
+ self.in_proj = nn.Linear(in_channels, config.hidden_size, bias=True)
66
+ self.cond_proj = nn.Linear(in_channels, config.hidden_size, bias=True)
67
+ self.out_proj = nn.Linear(config.hidden_size, self.out_channels, bias=True)
68
+
69
+ self.time_embeddings = SinusoidalPosEmb(config.hidden_size)
70
+ self.time_mlp = TimestepEmbedding(
71
+ in_channels=config.hidden_size,
72
+ time_embed_dim=config.hidden_size,
73
+ )
74
+ self.delta_time_mlp = TimestepEmbedding(
75
+ in_channels=config.hidden_size,
76
+ time_embed_dim=config.hidden_size,
77
+ )
78
+
79
+ assert config.vocab_size == 0, "vocab_size must be 0 for local DiT"
80
+ self.decoder = MiniCPMModel(config)
81
+
82
+ def forward(
83
+ self,
84
+ x: torch.Tensor,
85
+ mu: torch.Tensor,
86
+ t: torch.Tensor,
87
+ cond: torch.Tensor,
88
+ dt: torch.Tensor,
89
+ ):
90
+ """
91
+ Forward pass of DiT.
92
+ x: (N, C, T) tensor of inputs
93
+ mu: (N, C) tensor of hidden embedding
94
+ t: (N,) tensor of diffusion timesteps
95
+ cond: (N, C, T') tensor of prefix conditions
96
+ dt: (N,) used for mean velocity (may be supported in the future...)
97
+ """
98
+ x = self.in_proj(x.transpose(1, 2).contiguous())
99
+
100
+ cond = self.cond_proj(cond.transpose(1, 2).contiguous())
101
+ prefix = cond.size(1)
102
+
103
+ t = self.time_embeddings(t).to(x.dtype)
104
+ t = self.time_mlp(t)
105
+ dt = self.time_embeddings(dt).to(x.dtype)
106
+ dt = self.delta_time_mlp(dt)
107
+ t = t + dt
108
+
109
+ x = torch.cat([(mu + t).unsqueeze(1), cond, x], dim=1)
110
+ hidden, _ = self.decoder(x, is_causal=False)
111
+ hidden = hidden[:, prefix + 1 :, :]
112
+ hidden = self.out_proj(hidden)
113
+
114
+ return hidden.transpose(1, 2).contiguous()
bluemagpie/_vendor/voxcpm/modules/locdit/local_dit_v2.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from ..minicpm4 import MiniCPMModel, MiniCPM4Config
3
+ import torch.nn as nn
4
+ import math
5
+
6
+
7
+ class SinusoidalPosEmb(torch.nn.Module):
8
+ def __init__(self, dim):
9
+ super().__init__()
10
+ self.dim = dim
11
+ assert self.dim % 2 == 0, "SinusoidalPosEmb requires dim to be even"
12
+
13
+ def forward(self, x, scale=1000):
14
+ if x.ndim < 1:
15
+ x = x.unsqueeze(0)
16
+ device = x.device
17
+ half_dim = self.dim // 2
18
+ emb = math.log(10000) / (half_dim - 1)
19
+ emb = torch.exp(torch.arange(half_dim, dtype=x.dtype, device=device) * -emb)
20
+ emb = scale * x.unsqueeze(1) * emb.unsqueeze(0)
21
+ emb = torch.cat((emb.sin(), emb.cos()), dim=-1)
22
+ return emb
23
+
24
+
25
+ class TimestepEmbedding(nn.Module):
26
+ def __init__(
27
+ self,
28
+ in_channels: int,
29
+ time_embed_dim: int,
30
+ out_dim: int = None,
31
+ ):
32
+ super().__init__()
33
+
34
+ self.linear_1 = nn.Linear(in_channels, time_embed_dim, bias=True)
35
+ self.act = nn.SiLU()
36
+ if out_dim is not None:
37
+ time_embed_dim_out = out_dim
38
+ else:
39
+ time_embed_dim_out = time_embed_dim
40
+
41
+ self.linear_2 = nn.Linear(time_embed_dim, time_embed_dim_out, bias=True)
42
+
43
+ def forward(self, sample):
44
+ sample = self.linear_1(sample)
45
+ sample = self.act(sample)
46
+ sample = self.linear_2(sample)
47
+ return sample
48
+
49
+
50
+ class VoxCPMLocDiT(nn.Module):
51
+ """
52
+ Diffusion model with a Transformer backbone.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ config: MiniCPM4Config,
58
+ in_channels: int = 64,
59
+ ):
60
+ super().__init__()
61
+ self.in_channels = in_channels
62
+ self.out_channels = in_channels
63
+ self.config = config
64
+
65
+ self.in_proj = nn.Linear(in_channels, config.hidden_size, bias=True)
66
+ self.cond_proj = nn.Linear(in_channels, config.hidden_size, bias=True)
67
+ self.out_proj = nn.Linear(config.hidden_size, self.out_channels, bias=True)
68
+
69
+ self.time_embeddings = SinusoidalPosEmb(config.hidden_size)
70
+ self.time_mlp = TimestepEmbedding(
71
+ in_channels=config.hidden_size,
72
+ time_embed_dim=config.hidden_size,
73
+ )
74
+ self.delta_time_mlp = TimestepEmbedding(
75
+ in_channels=config.hidden_size,
76
+ time_embed_dim=config.hidden_size,
77
+ )
78
+
79
+ assert config.vocab_size == 0, "vocab_size must be 0 for local DiT"
80
+ self.decoder = MiniCPMModel(config)
81
+
82
+ def forward(
83
+ self,
84
+ x: torch.Tensor,
85
+ mu: torch.Tensor,
86
+ t: torch.Tensor,
87
+ cond: torch.Tensor,
88
+ dt: torch.Tensor,
89
+ ):
90
+ """
91
+ Forward pass of DiT.
92
+ x: (N, C, T) tensor of inputs
93
+ mu: (N, C) tensor of hidden embedding
94
+ t: (N,) tensor of diffusion timesteps
95
+ cond: (N, C, T') tensor of prefix conditions
96
+ dt: (N,) used for mean velocity (may be supported in the future...)
97
+ """
98
+ x = self.in_proj(x.transpose(1, 2).contiguous())
99
+
100
+ cond = self.cond_proj(cond.transpose(1, 2).contiguous())
101
+ prefix = cond.size(1)
102
+
103
+ t = self.time_embeddings(t).to(x.dtype)
104
+ t = self.time_mlp(t)
105
+ dt = self.time_embeddings(dt).to(x.dtype)
106
+ dt = self.delta_time_mlp(dt)
107
+ t = t + dt
108
+
109
+ mu = mu.view(x.size(0), -1, x.size(-1))
110
+ x = torch.cat([mu, (t).unsqueeze(1), cond, x], dim=1)
111
+
112
+ hidden, _ = self.decoder(x, is_causal=False)
113
+ hidden = hidden[:, prefix + mu.size(1) + 1 :, :]
114
+ hidden = self.out_proj(hidden)
115
+
116
+ return hidden.transpose(1, 2).contiguous()
bluemagpie/_vendor/voxcpm/modules/locdit/unified_cfm.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Tuple
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from torch.func import jvp
6
+ from pydantic import BaseModel
7
+
8
+ from .local_dit import VoxCPMLocDiT
9
+
10
+
11
+ class CfmConfig(BaseModel):
12
+ sigma_min: float = 1e-6
13
+ solver: str = "euler"
14
+ t_scheduler: str = "log-norm"
15
+ training_cfg_rate: float = 0.1
16
+ inference_cfg_rate: float = 1.0
17
+ reg_loss_type: str = "l1"
18
+ ratio_r_neq_t_range: Tuple[float, float] = (0.25, 0.75)
19
+ noise_cond_prob_range: Tuple[float, float] = (0.0, 0.0)
20
+ noise_cond_scale: float = 0.0
21
+
22
+
23
+ class UnifiedCFM(torch.nn.Module):
24
+ def __init__(
25
+ self,
26
+ in_channels: int,
27
+ cfm_params: CfmConfig,
28
+ estimator: VoxCPMLocDiT,
29
+ mean_mode: bool = False,
30
+ ):
31
+ super().__init__()
32
+ self.solver = cfm_params.solver
33
+ self.sigma_min = cfm_params.sigma_min
34
+ self.t_scheduler = cfm_params.t_scheduler
35
+ self.training_cfg_rate = cfm_params.training_cfg_rate
36
+ self.inference_cfg_rate = cfm_params.inference_cfg_rate
37
+ self.reg_loss_type = cfm_params.reg_loss_type
38
+ self.ratio_r_neq_t_range = cfm_params.ratio_r_neq_t_range
39
+ self.noise_cond_prob_range = cfm_params.noise_cond_prob_range
40
+ self.noise_cond_scale = cfm_params.noise_cond_scale
41
+
42
+ self.in_channels = in_channels
43
+ self.mean_mode = mean_mode
44
+
45
+ self.estimator = estimator
46
+
47
+ # ------------------------------------------------------------------ #
48
+ # Inference
49
+ # ------------------------------------------------------------------ #
50
+ @torch.inference_mode()
51
+ def forward(
52
+ self,
53
+ mu: torch.Tensor,
54
+ n_timesteps: int,
55
+ patch_size: int,
56
+ cond: torch.Tensor,
57
+ temperature: float = 1.0,
58
+ cfg_value: float = 1.0,
59
+ sway_sampling_coef: float = 1.0,
60
+ use_cfg_zero_star: bool = True,
61
+ ):
62
+ b, _ = mu.shape
63
+ t = patch_size
64
+ z = torch.randn((b, self.in_channels, t), device=mu.device, dtype=mu.dtype) * temperature
65
+
66
+ t_span = torch.linspace(1, 0, n_timesteps + 1, device=mu.device, dtype=mu.dtype)
67
+ t_span = t_span + sway_sampling_coef * (torch.cos(torch.pi / 2 * t_span) - 1 + t_span)
68
+
69
+ return self.solve_euler(
70
+ x=z,
71
+ t_span=t_span,
72
+ mu=mu,
73
+ cond=cond,
74
+ cfg_value=cfg_value,
75
+ use_cfg_zero_star=use_cfg_zero_star,
76
+ )
77
+
78
+ def optimized_scale(self, positive_flat: torch.Tensor, negative_flat: torch.Tensor):
79
+ dot_product = torch.sum(positive_flat * negative_flat, dim=1, keepdim=True)
80
+ squared_norm = torch.sum(negative_flat**2, dim=1, keepdim=True) + 1e-8
81
+ st_star = dot_product / squared_norm
82
+ return st_star
83
+
84
+ def solve_euler(
85
+ self,
86
+ x: torch.Tensor,
87
+ t_span: torch.Tensor,
88
+ mu: torch.Tensor,
89
+ cond: torch.Tensor,
90
+ cfg_value: float = 1.0,
91
+ use_cfg_zero_star: bool = True,
92
+ ):
93
+ t, _, dt = t_span[0], t_span[-1], t_span[0] - t_span[1]
94
+
95
+ sol = []
96
+ zero_init_steps = max(1, int(len(t_span) * 0.04))
97
+ for step in range(1, len(t_span)):
98
+ if use_cfg_zero_star and step <= zero_init_steps:
99
+ dphi_dt = torch.zeros_like(x)
100
+ else:
101
+ # Classifier-Free Guidance inference introduced in VoiceBox
102
+ b = x.size(0)
103
+ x_in = torch.zeros([2 * b, self.in_channels, x.size(2)], device=x.device, dtype=x.dtype)
104
+ mu_in = torch.zeros([2 * b, mu.size(1)], device=x.device, dtype=x.dtype)
105
+ t_in = torch.zeros([2 * b], device=x.device, dtype=x.dtype)
106
+ dt_in = torch.zeros([2 * b], device=x.device, dtype=x.dtype)
107
+ cond_in = torch.zeros([2 * b, self.in_channels, cond.size(2)], device=x.device, dtype=x.dtype)
108
+ x_in[:b], x_in[b:] = x, x
109
+ mu_in[:b] = mu
110
+ t_in[:b], t_in[b:] = t.unsqueeze(0), t.unsqueeze(0)
111
+ dt_in[:b], dt_in[b:] = dt.unsqueeze(0), dt.unsqueeze(0)
112
+ # not used now
113
+ if not self.mean_mode:
114
+ dt_in = torch.zeros_like(dt_in)
115
+ cond_in[:b], cond_in[b:] = cond, cond
116
+
117
+ dphi_dt = self.estimator(x_in, mu_in, t_in, cond_in, dt_in)
118
+ dphi_dt, cfg_dphi_dt = torch.split(dphi_dt, [x.size(0), x.size(0)], dim=0)
119
+
120
+ if use_cfg_zero_star:
121
+ positive_flat = dphi_dt.view(b, -1)
122
+ negative_flat = cfg_dphi_dt.view(b, -1)
123
+ st_star = self.optimized_scale(positive_flat, negative_flat)
124
+ st_star = st_star.view(b, *([1] * (len(dphi_dt.shape) - 1)))
125
+ else:
126
+ st_star = 1.0
127
+
128
+ dphi_dt = cfg_dphi_dt * st_star + cfg_value * (dphi_dt - cfg_dphi_dt * st_star)
129
+
130
+ x = x - dt * dphi_dt
131
+ t = t - dt
132
+ sol.append(x)
133
+ if step < len(t_span) - 1:
134
+ dt = t - t_span[step + 1]
135
+
136
+ return sol[-1]
137
+
138
+ # ------------------------------------------------------------------ #
139
+ # Training loss
140
+ # ------------------------------------------------------------------ #
141
+ def adaptive_loss_weighting(
142
+ self, losses: torch.Tensor, mask: torch.Tensor | None = None, p: float = 0.0, epsilon: float = 1e-3
143
+ ):
144
+ weights = 1.0 / ((losses + epsilon).pow(p))
145
+ if mask is not None:
146
+ weights = weights * mask
147
+ return weights.detach()
148
+
149
+ def sample_r_t(self, x: torch.Tensor, mu: float = -0.4, sigma: float = 1.0, ratio_r_neq_t: float = 0.0):
150
+ batch_size = x.shape[0]
151
+ if self.t_scheduler == "log-norm":
152
+ s_r = torch.randn(batch_size, device=x.device, dtype=x.dtype) * sigma + mu
153
+ s_t = torch.randn(batch_size, device=x.device, dtype=x.dtype) * sigma + mu
154
+ r = torch.sigmoid(s_r)
155
+ t = torch.sigmoid(s_t)
156
+ elif self.t_scheduler == "uniform":
157
+ r = torch.rand(batch_size, device=x.device, dtype=x.dtype)
158
+ t = torch.rand(batch_size, device=x.device, dtype=x.dtype)
159
+ else:
160
+ raise ValueError(f"Unsupported t_scheduler: {self.t_scheduler}")
161
+
162
+ mask = torch.rand(batch_size, device=x.device, dtype=x.dtype) < ratio_r_neq_t
163
+ r, t = torch.where(
164
+ mask,
165
+ torch.stack([torch.min(r, t), torch.max(r, t)], dim=0),
166
+ torch.stack([t, t], dim=0),
167
+ )
168
+
169
+ return r.squeeze(), t.squeeze()
170
+
171
+ def compute_loss(
172
+ self,
173
+ x1: torch.Tensor,
174
+ mu: torch.Tensor,
175
+ cond: torch.Tensor | None = None,
176
+ tgt_mask: torch.Tensor | None = None,
177
+ progress: float = 0.0,
178
+ ):
179
+ b, _, _ = x1.shape
180
+
181
+ if self.training_cfg_rate > 0:
182
+ cfg_mask = torch.rand(b, device=x1.device) > self.training_cfg_rate
183
+ mu = mu * cfg_mask.view(-1, 1)
184
+
185
+ if cond is None:
186
+ cond = torch.zeros_like(x1)
187
+
188
+ noisy_mask = torch.rand(b, device=x1.device) > (
189
+ 1.0
190
+ - (
191
+ self.noise_cond_prob_range[0]
192
+ + progress * (self.noise_cond_prob_range[1] - self.noise_cond_prob_range[0])
193
+ )
194
+ )
195
+ cond = cond + noisy_mask.view(-1, 1, 1) * torch.randn_like(cond) * self.noise_cond_scale
196
+
197
+ ratio_r_neq_t = (
198
+ self.ratio_r_neq_t_range[0] + progress * (self.ratio_r_neq_t_range[1] - self.ratio_r_neq_t_range[0])
199
+ if self.mean_mode
200
+ else 0.0
201
+ )
202
+
203
+ r, t = self.sample_r_t(x1, ratio_r_neq_t=ratio_r_neq_t)
204
+ r_ = r.detach().clone()
205
+ t_ = t.detach().clone()
206
+ z = torch.randn_like(x1)
207
+ y = (1 - t_.view(-1, 1, 1)) * x1 + t_.view(-1, 1, 1) * z
208
+ v = z - x1
209
+
210
+ def model_fn(z_sample, r_sample, t_sample):
211
+ return self.estimator(z_sample, mu, t_sample, cond, dt=t_sample - r_sample)
212
+
213
+ if self.mean_mode:
214
+ v_r = torch.zeros_like(r)
215
+ v_t = torch.ones_like(t)
216
+ from torch.backends.cuda import sdp_kernel
217
+
218
+ with sdp_kernel(enable_flash=False, enable_mem_efficient=False):
219
+ u_pred, dudt = jvp(model_fn, (y, r, t), (v, v_r, v_t))
220
+ u_tgt = v - (t_ - r_).view(-1, 1, 1) * dudt
221
+ else:
222
+ u_pred = model_fn(y, r, t)
223
+ u_tgt = v
224
+
225
+ losses = F.mse_loss(u_pred, u_tgt.detach(), reduction="none").mean(dim=1)
226
+ if tgt_mask is not None:
227
+ weights = self.adaptive_loss_weighting(losses, tgt_mask.squeeze(1))
228
+ loss = (weights * losses).sum() / torch.clamp(torch.sum(tgt_mask), min=1.0)
229
+ else:
230
+ loss = losses.mean()
231
+
232
+ return loss
bluemagpie/_vendor/voxcpm/modules/locenc/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .local_encoder import VoxCPMLocEnc
bluemagpie/_vendor/voxcpm/modules/locenc/local_encoder.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from ..minicpm4 import MiniCPMModel, MiniCPM4Config
4
+ from einops import rearrange
5
+
6
+
7
+ class VoxCPMLocEnc(nn.Module):
8
+ def __init__(self, config: MiniCPM4Config, input_dim: int = 64):
9
+ super().__init__()
10
+ self.config = config
11
+ self.special_token = nn.Parameter(torch.randn(1, 1, 1, config.hidden_size))
12
+ self.in_proj = nn.Linear(input_dim, config.hidden_size, bias=True)
13
+
14
+ assert config.vocab_size == 0, "vocab_size must be 0 for local encoder"
15
+ self.encoder = MiniCPMModel(config)
16
+
17
+ def forward(self, x):
18
+ """
19
+ x: [B, T, P, D]
20
+ """
21
+ B, T, P, D = x.shape
22
+
23
+ x = self.in_proj(x)
24
+ special_tokens = self.special_token.expand(B, T, 1, -1)
25
+ x = torch.cat([special_tokens, x], dim=2)
26
+ x = rearrange(x, "b t p c -> (b t) p c")
27
+ outputs, _ = self.encoder(x, is_causal=False)
28
+ cls_output = outputs[:, 0, :]
29
+
30
+ return rearrange(cls_output, "(b t) c -> b t c", b=B)
bluemagpie/_vendor/voxcpm/modules/minicpm4/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .config import MiniCPM4Config
2
+ from .model import MiniCPMModel
3
+ from .cache import StaticKVCache
bluemagpie/_vendor/voxcpm/modules/minicpm4/cache.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Tuple
2
+ import torch
3
+
4
+
5
+ class StaticKVCache:
6
+ def __init__(
7
+ self,
8
+ num_layers: int,
9
+ num_kv_heads: int,
10
+ dim_kv_head: int,
11
+ batch_size: int,
12
+ device: torch.device,
13
+ dtype: torch.dtype,
14
+ max_length: int = 8192,
15
+ ):
16
+ self.max_length = max_length
17
+ self.num_layers = num_layers
18
+
19
+ self.kv_cache = torch.zeros(
20
+ 2,
21
+ num_layers,
22
+ batch_size,
23
+ num_kv_heads,
24
+ max_length,
25
+ dim_kv_head,
26
+ device=device,
27
+ dtype=dtype,
28
+ )
29
+ self.current_length = 0
30
+
31
+ def get_layer_cache(self, layer_idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
32
+ return self.kv_cache[0, layer_idx], self.kv_cache[1, layer_idx]
33
+
34
+ def step(self) -> int:
35
+ if self.current_length >= self.max_length:
36
+ raise ValueError("KV cache is full")
37
+
38
+ ret = self.current_length
39
+ self.current_length += 1
40
+ return ret
41
+
42
+ def fill_caches(self, kv_caches: List[Tuple[torch.Tensor, torch.Tensor]]):
43
+ self.current_length = kv_caches[0][0].size(2)
44
+ self.kv_cache.zero_()
45
+ for i in range(self.num_layers):
46
+ self.kv_cache[0, i, :, :, : self.current_length, :] = kv_caches[i][0]
47
+ self.kv_cache[1, i, :, :, : self.current_length, :] = kv_caches[i][1]
bluemagpie/_vendor/voxcpm/modules/minicpm4/config.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List
3
+
4
+
5
+ class RopeScalingConfig(BaseModel):
6
+ type: str
7
+ long_factor: List[float]
8
+ short_factor: List[float]
9
+ original_max_position_embeddings: int
10
+
11
+
12
+ class MiniCPM4Config(BaseModel):
13
+ bos_token_id: int
14
+ eos_token_id: int
15
+ hidden_size: int
16
+ intermediate_size: int
17
+ max_position_embeddings: int
18
+ num_attention_heads: int
19
+ num_hidden_layers: int
20
+ num_key_value_heads: int
21
+ rms_norm_eps: float
22
+ rope_scaling: RopeScalingConfig
23
+ vocab_size: int
24
+ use_mup: bool = True
25
+ scale_emb: float
26
+ dim_model_base: int
27
+ scale_depth: float
28
+ rope_theta: float
29
+ kv_channels: int = None
30
+ no_rope: bool = False
bluemagpie/_vendor/voxcpm/modules/minicpm4/model.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .config import MiniCPM4Config
2
+ import torch
3
+ import torch.nn as nn
4
+ from typing import List, Tuple
5
+ import math
6
+ from .cache import StaticKVCache
7
+
8
+
9
+ def rms_layernorm(hidden: torch.Tensor, weight: torch.Tensor, eps: float):
10
+ old_dtype = hidden.dtype
11
+ variance = hidden.to(torch.float32).pow(2).mean(dim=-1, keepdim=True)
12
+ hidden = (hidden * torch.rsqrt(variance + eps)).to(old_dtype)
13
+ return hidden * weight
14
+
15
+
16
+ class MiniCPMRMSNorm(nn.Module):
17
+ def __init__(self, hidden_size, eps=1e-6):
18
+ """
19
+ MiniCPMRMSNorm is equivalent to T5LayerNorm
20
+ """
21
+ super().__init__()
22
+ self.weight = nn.Parameter(torch.ones(hidden_size))
23
+ self.variance_epsilon = eps
24
+
25
+ def forward(self, hidden_states):
26
+ return rms_layernorm(hidden_states, self.weight, self.variance_epsilon)
27
+
28
+
29
+ def rotate_half(x):
30
+ """Rotates half the hidden dims of the input."""
31
+ x1, x2 = x.chunk(2, dim=-1)
32
+ return torch.cat((-x2, x1), dim=-1)
33
+
34
+
35
+ def apply_rotary_pos_emb(q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor):
36
+ """
37
+ Args:
38
+ q: Tensor(batch_size, num_heads, seq_len, head_dim)
39
+ k: Tensor(batch_size, num_key_value_heads, seq_len, head_dim)
40
+ cos: Tensor(seq_len, head_dim)
41
+ sin: Tensor(seq_len, head_dim)
42
+ Returns:
43
+ Tensor(batch_size, num_heads, seq_len, head_dim), Tensor(batch_size, num_key_value_heads, seq_len, head_dim)
44
+ """
45
+ orig_dtype = q.dtype
46
+ q = q.to(torch.float32)
47
+ k = k.to(torch.float32)
48
+ q_embed = (q * cos) + (rotate_half(q) * sin)
49
+ k_embed = (k * cos) + (rotate_half(k) * sin)
50
+ return q_embed.to(orig_dtype), k_embed.to(orig_dtype)
51
+
52
+
53
+ class MiniCPMLongRoPE(nn.Module):
54
+ """MiniCPMRotaryEmbedding extended with Dynamic NTK scaling. Credits to the Reddit users /u/bloc97 and /u/emozilla"""
55
+
56
+ def __init__(self, config: MiniCPM4Config):
57
+ super().__init__()
58
+ self.config = config
59
+ self.dim = config.kv_channels if config.kv_channels else config.hidden_size // config.num_attention_heads
60
+ self.base = config.rope_theta
61
+ self.max_position_embeddings = config.max_position_embeddings
62
+
63
+ self.short_factor = config.rope_scaling.short_factor
64
+ self.long_factor = config.rope_scaling.long_factor
65
+ self.original_max_position_embeddings = config.rope_scaling.original_max_position_embeddings
66
+
67
+ scale = self.max_position_embeddings / self.original_max_position_embeddings
68
+ self.scaling_factor = math.sqrt(1 + math.log(scale) / math.log(self.original_max_position_embeddings))
69
+ inv_freq = 1.0 / (self.base ** (torch.arange(0, self.dim, 2).float() / self.dim))
70
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
71
+
72
+ self.max_seq_len_cached = 0
73
+
74
+ self.register_buffer("cos_cached", torch.empty(0), persistent=False)
75
+ self.register_buffer("sin_cached", torch.empty(0), persistent=False)
76
+
77
+ self._set_cos_sin_cache(seq_len=self.max_position_embeddings, device=self.inv_freq.device, dtype=torch.float32)
78
+
79
+ def _set_cos_sin_cache(self, seq_len, device, dtype):
80
+ """设置cos和sin缓存"""
81
+ self.max_seq_len_cached = seq_len
82
+ t = torch.arange(self.max_seq_len_cached, device=device, dtype=self.inv_freq.dtype)
83
+
84
+ if seq_len > self.original_max_position_embeddings:
85
+ ext_factors = torch.tensor(self.long_factor, dtype=torch.float32, device=device)
86
+ else:
87
+ ext_factors = torch.tensor(self.short_factor, dtype=torch.float32, device=device)
88
+
89
+ freqs = torch.mul(
90
+ torch.outer(t, 1.0 / ext_factors).to(device=device), self.inv_freq.to(device=device).to(dtype)
91
+ )
92
+
93
+ # 创建embeddings
94
+ emb = torch.cat((freqs, freqs), dim=-1)
95
+
96
+ self.cos_cached = emb.cos().to(dtype) * self.scaling_factor
97
+ self.sin_cached = emb.sin().to(dtype) * self.scaling_factor
98
+
99
+ def forward(self, position_ids: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
100
+ """
101
+ Args:
102
+ position_ids: Tensor(seq_len) 或 Tensor(batch_size, seq_len)
103
+ Returns:
104
+ Tensor(seq_len, head_dim), Tensor(seq_len, head_dim)
105
+ """
106
+ cos = self.cos_cached[position_ids]
107
+ sin = self.sin_cached[position_ids]
108
+
109
+ return cos, sin
110
+
111
+
112
+ class MiniCPMAttention(nn.Module):
113
+ def __init__(self, config: MiniCPM4Config, layer_idx: int):
114
+ super().__init__()
115
+ self.config = config
116
+ self.layer_idx = layer_idx
117
+ self.hidden_size = config.hidden_size
118
+ self.num_heads = config.num_attention_heads
119
+ self.head_dim = (
120
+ config.hidden_size // config.num_attention_heads if config.kv_channels is None else config.kv_channels
121
+ )
122
+ self.num_key_value_heads = config.num_key_value_heads
123
+ self.num_key_value_groups = self.num_heads // self.num_key_value_heads
124
+ self.max_position_embeddings = config.max_position_embeddings
125
+ self.rope_theta = 10000.0
126
+
127
+ self.q_proj = nn.Linear(self.hidden_size, self.num_heads * self.head_dim, bias=False)
128
+ self.k_proj = nn.Linear(self.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
129
+ self.v_proj = nn.Linear(self.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
130
+ self.o_proj = nn.Linear(self.num_heads * self.head_dim, self.hidden_size, bias=False)
131
+
132
+ def forward(
133
+ self,
134
+ hidden_states: torch.Tensor,
135
+ position_emb: Tuple[torch.Tensor, torch.Tensor],
136
+ is_causal: bool,
137
+ ) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
138
+ bsz, q_len, _ = hidden_states.size()
139
+
140
+ query_states = self.q_proj(hidden_states)
141
+ key_states = self.k_proj(hidden_states)
142
+ value_states = self.v_proj(hidden_states)
143
+
144
+ query_states = query_states.view(bsz, q_len, self.num_heads, self.head_dim).transpose(1, 2)
145
+ key_states = key_states.view(bsz, q_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)
146
+ value_states = value_states.view(bsz, q_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)
147
+
148
+ if position_emb is not None:
149
+ cos, sin = position_emb
150
+ query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)
151
+
152
+ # ref: https://github.com/pytorch/pytorch/issues/163597
153
+ # there is a bug in MPS for non-contiguous tensors, so we need to make them contiguous
154
+ query_states = query_states.contiguous()
155
+ key_states = key_states.contiguous()
156
+ value_states = value_states.contiguous()
157
+ attn_output = torch.nn.functional.scaled_dot_product_attention(
158
+ query_states,
159
+ key_states,
160
+ value_states,
161
+ is_causal=is_causal,
162
+ enable_gqa=True,
163
+ )
164
+
165
+ attn_output = attn_output.transpose(1, 2).contiguous()
166
+ attn_output = attn_output.reshape(bsz, q_len, self.num_heads * self.head_dim)
167
+
168
+ attn_output = self.o_proj(attn_output)
169
+
170
+ past_key_value = (key_states, value_states)
171
+ return attn_output, past_key_value
172
+
173
+ def forward_step(
174
+ self,
175
+ hidden_states: torch.Tensor,
176
+ position_emb: Tuple[torch.Tensor, torch.Tensor],
177
+ position_id: int,
178
+ kv_cache: Tuple[torch.Tensor, torch.Tensor],
179
+ ) -> torch.Tensor:
180
+ bsz, _ = hidden_states.size()
181
+
182
+ query_states = self.q_proj(hidden_states)
183
+ key_states = self.k_proj(hidden_states)
184
+ value_states = self.v_proj(hidden_states)
185
+
186
+ query_states = query_states.view(bsz, 1, self.num_heads, self.head_dim).transpose(1, 2)
187
+ key_states = key_states.view(bsz, 1, self.num_key_value_heads, self.head_dim).transpose(1, 2)
188
+ value_states = value_states.view(bsz, 1, self.num_key_value_heads, self.head_dim).transpose(1, 2)
189
+
190
+ if position_emb is not None:
191
+ cos, sin = position_emb
192
+ query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)
193
+
194
+ key_cache, value_cache = kv_cache
195
+
196
+ key_cache[:, :, position_id, :] = key_states
197
+ value_cache[:, :, position_id, :] = value_states
198
+
199
+ # Use an explicit broadcastable mask shape for SDPA. A 1D mask can
200
+ # trigger a CPU-side dimension bug in some PyTorch versions.
201
+ attn_mask = (torch.arange(key_cache.size(2), device=key_cache.device) <= position_id).view(1, 1, 1, -1)
202
+
203
+ # ref: https://github.com/pytorch/pytorch/issues/163597
204
+ # there is a bug in MPS for non-contiguous tensors, so we need to make them contiguous
205
+ query_states = query_states.contiguous()
206
+ key_cache = key_cache.contiguous()
207
+ value_cache = value_cache.contiguous()
208
+ attn_output = torch.nn.functional.scaled_dot_product_attention(
209
+ query_states,
210
+ key_cache,
211
+ value_cache,
212
+ attn_mask=attn_mask,
213
+ enable_gqa=True,
214
+ )
215
+
216
+ attn_output = attn_output.transpose(1, 2).contiguous()
217
+ attn_output = attn_output.reshape(bsz, self.num_heads * self.head_dim)
218
+ attn_output = self.o_proj(attn_output)
219
+
220
+ return attn_output
221
+
222
+
223
+ class MiniCPMMLP(nn.Module):
224
+ def __init__(self, config):
225
+ super().__init__()
226
+ self.config = config
227
+ self.hidden_size = config.hidden_size
228
+ self.intermediate_size = config.intermediate_size
229
+ self.gate_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False)
230
+ self.up_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False)
231
+ self.down_proj = nn.Linear(self.intermediate_size, self.hidden_size, bias=False)
232
+ self.act_fn = nn.SiLU()
233
+
234
+ def forward(self, x):
235
+ return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
236
+
237
+
238
+ class MiniCPMDecoderLayer(nn.Module):
239
+ def __init__(self, config: MiniCPM4Config, layer_idx: int):
240
+ super().__init__()
241
+ self.hidden_size = config.hidden_size
242
+ self.self_attn = MiniCPMAttention(config=config, layer_idx=layer_idx)
243
+
244
+ self.mlp = MiniCPMMLP(config)
245
+ self.input_layernorm = MiniCPMRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
246
+ self.post_attention_layernorm = MiniCPMRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
247
+
248
+ self.scale_depth = config.scale_depth
249
+ self.num_hidden_layers = config.num_hidden_layers
250
+ self.use_mup = config.use_mup
251
+
252
+ def forward(
253
+ self,
254
+ hidden_states: torch.Tensor,
255
+ position_emb: Tuple[torch.Tensor, torch.Tensor],
256
+ is_causal: bool,
257
+ ) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
258
+ """
259
+ Args:
260
+ hidden_states (`torch.FloatTensor`): input to the layer of shape `(batch, seq_len, embed_dim)`
261
+ position_ids (`torch.LongTensor`): position ids of shape `(batch_size, seq_len)`
262
+ is_causal (`bool`): whether the attention mask is causal
263
+ """
264
+ residual = hidden_states
265
+ hidden_states = self.input_layernorm(hidden_states)
266
+ # Self Attention
267
+ hidden_states, present_key_value = self.self_attn(
268
+ hidden_states=hidden_states,
269
+ position_emb=position_emb,
270
+ is_causal=is_causal,
271
+ )
272
+
273
+ if self.use_mup:
274
+ hidden_states = residual + hidden_states * (self.scale_depth / math.sqrt(self.num_hidden_layers))
275
+ else:
276
+ hidden_states = residual + hidden_states
277
+
278
+ # Fully Connected
279
+ residual = hidden_states
280
+ hidden_states = self.post_attention_layernorm(hidden_states)
281
+
282
+ hidden_states = self.mlp(hidden_states)
283
+ if self.use_mup:
284
+ hidden_states = residual + hidden_states * (self.scale_depth / math.sqrt(self.num_hidden_layers))
285
+ else:
286
+ hidden_states = residual + hidden_states
287
+
288
+ return hidden_states, present_key_value
289
+
290
+ def forward_step(
291
+ self,
292
+ hidden_states: torch.Tensor,
293
+ position_emb: Tuple[torch.Tensor, torch.Tensor],
294
+ position_id: torch.Tensor,
295
+ kv_cache: Tuple[torch.Tensor, torch.Tensor],
296
+ ) -> torch.Tensor:
297
+ residual = hidden_states
298
+ hidden_states = self.input_layernorm(hidden_states)
299
+ # Self Attention
300
+ hidden_states = self.self_attn.forward_step(
301
+ hidden_states=hidden_states,
302
+ position_emb=position_emb,
303
+ position_id=position_id,
304
+ kv_cache=kv_cache,
305
+ )
306
+
307
+ if self.use_mup:
308
+ hidden_states = residual + hidden_states * (self.scale_depth / math.sqrt(self.num_hidden_layers))
309
+ else:
310
+ hidden_states = residual + hidden_states
311
+
312
+ # Fully Connected
313
+ residual = hidden_states
314
+ hidden_states = self.post_attention_layernorm(hidden_states)
315
+
316
+ hidden_states = self.mlp(hidden_states)
317
+ if self.use_mup:
318
+ hidden_states = residual + hidden_states * (self.scale_depth / math.sqrt(self.num_hidden_layers))
319
+ else:
320
+ hidden_states = residual + hidden_states
321
+
322
+ return hidden_states
323
+
324
+
325
+ class MiniCPMModel(nn.Module):
326
+ """
327
+ Transformer decoder consisting of *config.num_hidden_layers* layers. Each layer is a [`MiniCPMDecoderLayer`]
328
+
329
+ Args:
330
+ config: MiniCPMConfig
331
+ """
332
+
333
+ def __init__(self, config: MiniCPM4Config):
334
+ super().__init__()
335
+ self.vocab_size = config.vocab_size
336
+ self.config = config
337
+
338
+ if config.vocab_size > 0:
339
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
340
+ else:
341
+ self.embed_tokens = nn.Identity()
342
+
343
+ self.layers = nn.ModuleList(
344
+ [MiniCPMDecoderLayer(config, layer_idx) for layer_idx in range(config.num_hidden_layers)]
345
+ )
346
+
347
+ self.norm = MiniCPMRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
348
+ if config.no_rope:
349
+ self.rope_emb = None
350
+ else:
351
+ self.rope_emb = MiniCPMLongRoPE(config)
352
+
353
+ self.kv_cache = None
354
+
355
+ def forward(
356
+ self,
357
+ inputs_embeds: torch.Tensor,
358
+ is_causal: bool = True,
359
+ ) -> Tuple[torch.Tensor, List[Tuple[torch.Tensor, torch.Tensor]]]:
360
+ """
361
+ Args:
362
+ inputs_embeds: Tensor(batch_size, seq_length, hidden_size)
363
+ is_causal: bool, whether the attention mask is causal
364
+ Returns:
365
+ hidden_states: Tensor(batch_size, seq_length, hidden_size)
366
+ next_decoder_cache: List[(batch_size, num_heads, seq_length, head_dim), (batch_size, num_heads, seq_length, head_dim)]
367
+ """
368
+ if self.rope_emb is not None:
369
+ position_ids = torch.arange(0, inputs_embeds.size(1), dtype=torch.long, device=inputs_embeds.device)
370
+ position_emb = self.rope_emb(position_ids)
371
+ else:
372
+ position_emb = None
373
+ hidden_states = inputs_embeds
374
+
375
+ next_decoder_cache = []
376
+
377
+ for decoder_layer in self.layers:
378
+
379
+ hidden_states, this_cache = decoder_layer(
380
+ hidden_states,
381
+ position_emb,
382
+ is_causal,
383
+ )
384
+ next_decoder_cache.append(this_cache)
385
+ hidden_states = self.norm(hidden_states)
386
+ return hidden_states, next_decoder_cache
387
+
388
+ def forward_step(
389
+ self,
390
+ inputs_embeds: torch.Tensor,
391
+ position_id: torch.Tensor,
392
+ ) -> torch.Tensor:
393
+ """
394
+ Args:
395
+ inputs_embeds: Tensor(batch_size, hidden_size)
396
+ Returns:
397
+ hidden_states: Tensor(batch_size, hidden_size)
398
+ """
399
+ assert self.kv_cache is not None, "KV cache is not setup"
400
+
401
+ if self.rope_emb is not None:
402
+ position_emb = self.rope_emb(position_id)
403
+ else:
404
+ position_emb = None
405
+ hidden_states = inputs_embeds
406
+
407
+ for i, decoder_layer in enumerate(self.layers):
408
+ hidden_states = decoder_layer.forward_step(
409
+ hidden_states,
410
+ position_emb,
411
+ position_id,
412
+ self.kv_cache.get_layer_cache(i),
413
+ )
414
+
415
+ hidden_states = self.norm(hidden_states)
416
+ return hidden_states
417
+
418
+ def setup_cache(self, batch_size: int, max_length: int, device, dtype: torch.dtype):
419
+ self.kv_cache = StaticKVCache(
420
+ num_layers=self.config.num_hidden_layers,
421
+ num_kv_heads=self.config.num_key_value_heads,
422
+ dim_kv_head=(
423
+ self.config.hidden_size // self.config.num_attention_heads
424
+ if self.config.kv_channels is None
425
+ else self.config.kv_channels
426
+ ),
427
+ batch_size=batch_size,
428
+ device=device,
429
+ dtype=dtype,
430
+ max_length=max_length,
431
+ )
bluemagpie/adapter.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Projection adapter between Barbet hidden space and VoxCPM2 LM hidden space.
2
+
3
+ Barbet's hidden states are not drop-in compatible with the space VoxCPM2's
4
+ pretrained downstream modules (FSQ layer, RALM fusion, DiT projections, stop
5
+ head) were trained in. The adapter is the explicit bridge:
6
+
7
+ barbet_hidden (H_b) --RMSNorm--Linear--> H_v --[zero-init residual MLP]*N--> H_v
8
+
9
+ The residual MLP blocks have their output projections zero-initialized, so at
10
+ init the adapter is exactly RMSNorm + Linear. Training can then grow extra
11
+ capacity without perturbing the warm-start behaviour.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import torch
17
+ import torch.nn.functional as F
18
+ from torch import nn
19
+
20
+ from .config import AdapterConfig
21
+
22
+
23
+ class RMSNorm(nn.Module):
24
+ def __init__(self, hidden_size: int, eps: float = 1.0e-6) -> None:
25
+ super().__init__()
26
+ self.weight = nn.Parameter(torch.ones(hidden_size))
27
+ self.eps = eps
28
+
29
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
30
+ variance = x.float().pow(2).mean(dim=-1, keepdim=True)
31
+ x = x.float() * torch.rsqrt(variance + self.eps)
32
+ return x.to(dtype=self.weight.dtype) * self.weight
33
+
34
+
35
+ class ResidualSwiGLUBlock(nn.Module):
36
+ def __init__(self, dim: int, ffn_mult: float, eps: float) -> None:
37
+ super().__init__()
38
+ inner = int(dim * ffn_mult)
39
+ self.norm = RMSNorm(dim, eps)
40
+ self.gate_proj = nn.Linear(dim, inner, bias=False)
41
+ self.up_proj = nn.Linear(dim, inner, bias=False)
42
+ self.down_proj = nn.Linear(inner, dim, bias=False)
43
+ nn.init.zeros_(self.down_proj.weight)
44
+
45
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
46
+ h = self.norm(x)
47
+ return x + self.down_proj(F.silu(self.gate_proj(h)) * self.up_proj(h))
48
+
49
+
50
+ class ProjectionAdapter(nn.Module):
51
+ """Maps Barbet hidden states into the VoxCPM2 semantic LM space."""
52
+
53
+ def __init__(self, in_dim: int, out_dim: int, config: AdapterConfig) -> None:
54
+ super().__init__()
55
+ self.norm = RMSNorm(in_dim, config.rms_norm_eps)
56
+ self.proj = nn.Linear(in_dim, out_dim)
57
+ self.blocks = nn.ModuleList(
58
+ [
59
+ ResidualSwiGLUBlock(out_dim, config.ffn_mult, config.rms_norm_eps)
60
+ for _ in range(config.num_residual_blocks)
61
+ ]
62
+ )
63
+
64
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
65
+ x = self.proj(self.norm(x))
66
+ for block in self.blocks:
67
+ x = block(x)
68
+ return x
bluemagpie/centroid.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extract a speaker centroid from reference audio, for voice cloning.
2
+
3
+ Computes an ECAPA-TDNN centroid in the same space as the model's training
4
+ centroids — the same mechanism the bundled ``hung_yi_lee`` example uses — ready
5
+ to pass as ``model.generate(..., speaker_centroid=centroid)``.
6
+
7
+ Requires ``speechbrain`` (an optional extra): ``pip install -e ".[clone]"``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Sequence, Union
13
+
14
+ import numpy as np
15
+ import torch
16
+
17
+ DEFAULT_ECAPA = "speechbrain/spkrec-ecapa-voxceleb"
18
+ _SR = 16000
19
+
20
+
21
+ def _load_encoder(model_id: str, device: str):
22
+ try:
23
+ from speechbrain.inference.speaker import EncoderClassifier
24
+ except Exception as e: # pragma: no cover - heavy optional dep
25
+ raise ImportError(
26
+ f"speechbrain is required to extract speaker centroids ({e}). "
27
+ 'Install with: pip install -e ".[clone]" (or: pip install speechbrain)'
28
+ ) from e
29
+ return EncoderClassifier.from_hparams(source=model_id, run_opts={"device": device})
30
+
31
+
32
+ @torch.no_grad()
33
+ def extract_speaker_centroid(
34
+ audio: Union[str, Sequence[str]],
35
+ *,
36
+ ecapa_model: str = DEFAULT_ECAPA,
37
+ device: str = "cpu",
38
+ window_s: float = 6.0,
39
+ encoder=None,
40
+ ) -> torch.Tensor:
41
+ """One or more reference clips (same speaker) -> a ``[192]`` L2-normalized centroid.
42
+
43
+ Matches ``scripts/build_speaker_centroids.py``: each ~``window_s`` chunk is
44
+ ECAPA-embedded and L2-normalized, all chunks are averaged, and the mean is
45
+ L2-normalized — so the result lives in the model's training-centroid space.
46
+ Pass a single path or a list of paths. Provide a loaded ``encoder`` to reuse it.
47
+ """
48
+ import librosa
49
+
50
+ paths = [audio] if isinstance(audio, str) else list(audio)
51
+ clf = encoder if encoder is not None else _load_encoder(ecapa_model, device)
52
+ win = int(window_s * _SR)
53
+ embs = []
54
+ for path in paths:
55
+ wav, _ = librosa.load(path, sr=_SR, mono=True)
56
+ x = torch.from_numpy(np.ascontiguousarray(wav)).float()
57
+ chunks = [x[i : i + win] for i in range(0, x.numel(), win)]
58
+ chunks = [c for c in chunks if c.numel() >= _SR]
59
+ if not chunks and x.numel() > 0:
60
+ chunks = [x] # shorter than 1 s: use as-is
61
+ for c in chunks:
62
+ e = clf.encode_batch(c.unsqueeze(0).to(device)).reshape(-1) # [192]
63
+ embs.append(torch.nn.functional.normalize(e, dim=0).cpu())
64
+ if not embs:
65
+ raise ValueError("no usable audio; provide ~3-10 s of clean single-speaker speech")
66
+ mean = torch.stack(embs).mean(0)
67
+ return torch.nn.functional.normalize(mean, dim=0)
bluemagpie/conditioning.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speaker conditioning for BlueMagpie-TTS (v1).
2
+
3
+ v1 conditions on a *speaker centroid* — a per-speaker ECAPA-TDNN embedding
4
+ averaged over several clips, computed offline. The centroid carries the constant part
5
+ of a voice (identity, timbre, baseline pitch); using a denoised per-speaker
6
+ average rather than a per-utterance embedding prevents the model from leaking
7
+ that clip's prosody/content as a shortcut.
8
+
9
+ ``SpeakerProjector`` maps the centroid into the Barbet hidden space; the
10
+ resulting vector is placed at the ``[spk]`` token slot in the input sequence
11
+ (see ``BlueMagpieModel``), so Barbet's causal attention propagates the speaker
12
+ identity into every downstream position.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import torch
18
+ from torch import nn
19
+
20
+ from .adapter import RMSNorm
21
+
22
+
23
+ class SpeakerProjector(nn.Module):
24
+ """Project an L2-normalized speaker centroid into the Barbet hidden space."""
25
+
26
+ def __init__(self, in_dim: int, out_dim: int, eps: float = 1.0e-6) -> None:
27
+ super().__init__()
28
+ self.norm = RMSNorm(in_dim, eps)
29
+ self.proj = nn.Linear(in_dim, out_dim)
30
+ # Small init so a cold-started speaker vector does not destabilize the
31
+ # warm-started (pretrained) Barbet backbone early in training.
32
+ nn.init.normal_(self.proj.weight, mean=0.0, std=0.02)
33
+ nn.init.zeros_(self.proj.bias)
34
+
35
+ def forward(self, centroid: torch.Tensor) -> torch.Tensor:
36
+ """``centroid``: [B, in_dim] -> [B, out_dim]."""
37
+ return self.proj(self.norm(centroid))
bluemagpie/config.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for BlueMagpie-TTS.
2
+
3
+ BlueMagpie = Barbet (TSLM) + VoxCPM2 acoustic stack (LocEnc / RALM / LocDiT / AudioVAE).
4
+
5
+ The config keeps two "hidden spaces" explicit:
6
+
7
+ - ``barbet_config`` -> Barbet's hidden space (H_b, e.g. 1024 for 300M, 1536 for 1B)
8
+ - ``vox_lm_config`` -> VoxCPM2's semantic LM hidden space (H_v, 2048 for openbmb/VoxCPM2)
9
+
10
+ ``vox_lm_config`` is the MiniCPM4 config of the *original* VoxCPM2 TSLM. The full
11
+ 28-layer TSLM is never instantiated here — the config object is kept because it is
12
+ the template from which VoxCPM2 derives its RALM / LocEnc / LocDiT submodule
13
+ configs, and because ``hidden_size`` defines the space all pretrained projection
14
+ layers (fsq, fusion_concat_proj, lm_to_dit_proj, ...) live in.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional
20
+
21
+ from pydantic import BaseModel
22
+ from bluemagpie._vendor.voxcpm.model.voxcpm2 import VoxCPMDitConfig, VoxCPMEncoderConfig
23
+ from bluemagpie._vendor.voxcpm.modules.audiovae import AudioVAEConfigV2
24
+ from bluemagpie._vendor.voxcpm.modules.minicpm4 import MiniCPM4Config
25
+
26
+
27
+ class AdapterConfig(BaseModel):
28
+ """Projection adapter bridging Barbet hidden space into VoxCPM2 LM space."""
29
+
30
+ num_residual_blocks: int = 1
31
+ ffn_mult: float = 2.0
32
+ rms_norm_eps: float = 1.0e-6
33
+
34
+
35
+ class BlueMagpieConfig(BaseModel):
36
+ # Raw kwargs for barbet.BarbetConfig (kept as dict so the pydantic model
37
+ # stays serializable independently of transformers).
38
+ barbet_config: dict
39
+
40
+ # Semantic-space template (the original VoxCPM2 TSLM config).
41
+ vox_lm_config: MiniCPM4Config
42
+
43
+ patch_size: int = 4
44
+ feat_dim: int = 64
45
+ residual_lm_num_layers: int = 8
46
+ residual_lm_no_rope: bool = False
47
+ scalar_quantization_latent_dim: int = 512
48
+ scalar_quantization_scale: int = 9
49
+
50
+ encoder_config: VoxCPMEncoderConfig
51
+ dit_config: VoxCPMDitConfig
52
+ audio_vae_config: Optional[AudioVAEConfigV2] = None
53
+
54
+ adapter_config: AdapterConfig = AdapterConfig()
55
+
56
+ # Dimensionality of the speaker centroid (ECAPA-TDNN default = 192). The
57
+ # SpeakerProjector maps this into the Barbet hidden space at the [spk] slot.
58
+ speaker_embed_dim: int = 192
59
+
60
+ # Special token ids in *Barbet* vocab space. -1 means "allocate the first
61
+ # free id after the effective vocab" (resolved by resolve_barbet_config()).
62
+ audio_start_token: int = -1
63
+ audio_end_token: int = -1
64
+ ref_audio_start_token: int = -1
65
+ ref_audio_end_token: int = -1
66
+ # Placeholder slot whose embedding row is overwritten by the projected
67
+ # speaker centroid (and serves as the learned "null speaker" when dropped).
68
+ spk_token: int = -1
69
+
70
+ # Number of ids actually used by the tokenizer. Barbet R2 checkpoints pad
71
+ # the embedding to a multiple of 128 for Megatron (vocab_size 114944 vs
72
+ # effective 114822 for PangolinTokenizer), so auto-allocated special
73
+ # tokens can live in the padding region without growing the embedding.
74
+ # None -> auto-detect from the Pangolin contract, else fall back to
75
+ # vocab_size (append-and-grow).
76
+ barbet_effective_vocab_size: Optional[int] = None
77
+
78
+ max_length: int = 8192
79
+ device: str = "cuda"
80
+ dtype: str = "bfloat16"
81
+
82
+ def _effective_vocab_size(self, barbet_cfg) -> int:
83
+ """First id available for auto-allocated special tokens."""
84
+ if self.barbet_effective_vocab_size is not None:
85
+ return self.barbet_effective_vocab_size
86
+ try:
87
+ from barbet.configuration_barbet import EFFECTIVE_VOCAB_SIZE, MEGATRON_PADDED_VOCAB_SIZE
88
+
89
+ # Pangolin contract: ids [EFFECTIVE, PADDED) are Megatron padding
90
+ # rows the tokenizer never produces — free for our special tokens.
91
+ if barbet_cfg.vocab_size == MEGATRON_PADDED_VOCAB_SIZE:
92
+ return EFFECTIVE_VOCAB_SIZE
93
+ except ImportError: # pre-R2 barbet without the contract constants
94
+ pass
95
+ return barbet_cfg.vocab_size
96
+
97
+ def resolve_barbet_config(self):
98
+ """Build the BarbetConfig and resolve special-token ids.
99
+
100
+ Returns:
101
+ (barbet_config, token_ids) where token_ids is a dict with keys
102
+ audio_start/audio_end/ref_audio_start/ref_audio_end/spk. Ids set to
103
+ -1 are allocated from the first free id after the effective vocab —
104
+ inside the Megatron padding region for R2 checkpoints (no
105
+ embedding growth), or appended after the vocab otherwise. The
106
+ vocab only grows when an id falls beyond the current size.
107
+ """
108
+ from barbet import BarbetConfig
109
+
110
+ kwargs = dict(self.barbet_config)
111
+ barbet_cfg = BarbetConfig(**kwargs)
112
+
113
+ names = ["audio_start", "audio_end", "ref_audio_start", "ref_audio_end", "spk"]
114
+ requested = [
115
+ self.audio_start_token,
116
+ self.audio_end_token,
117
+ self.ref_audio_start_token,
118
+ self.ref_audio_end_token,
119
+ self.spk_token,
120
+ ]
121
+ token_ids = {}
122
+ next_id = self._effective_vocab_size(barbet_cfg)
123
+ for name, tok in zip(names, requested):
124
+ if tok is None or tok < 0:
125
+ token_ids[name] = next_id
126
+ next_id += 1
127
+ else:
128
+ token_ids[name] = tok
129
+ needed = max(token_ids.values()) + 1
130
+ if needed > barbet_cfg.vocab_size:
131
+ barbet_cfg.vocab_size = needed
132
+ return barbet_cfg, token_ids
133
+
134
+
135
+ BlueMagpieConfig.model_rebuild()
bluemagpie/loading.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Checkpoint assembly: pretrained VoxCPM2 acoustic stack + Barbet TSLM.
2
+
3
+ The hybrid is built by weight surgery:
4
+
5
+ - from the VoxCPM2 checkpoint we keep every module *except* the TSLM
6
+ (``base_lm.*`` keys are dropped): LocEnc, RALM, LocDiT, FSQ, the projection
7
+ layers, the stop head and the AudioVAE.
8
+ - Barbet weights are loaded into ``base_lm.backbone`` (with the 4 appended
9
+ special-token embedding rows freshly initialized).
10
+ - the two bridge modules (``enc_to_tslm_proj``, ``tslm_adapter``) are always
11
+ freshly initialized — they are what training stage 0/1 is for.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+ from typing import Optional
20
+
21
+ import torch
22
+
23
+ from bluemagpie._vendor.voxcpm.model.voxcpm2 import VoxCPMConfig
24
+ from bluemagpie._vendor.voxcpm.modules.audiovae import AudioVAEV2
25
+
26
+ from .config import AdapterConfig, BlueMagpieConfig
27
+ from .model import BlueMagpieModel
28
+
29
+ try:
30
+ from safetensors.torch import load_file
31
+
32
+ SAFETENSORS_AVAILABLE = True
33
+ except ImportError: # pragma: no cover
34
+ SAFETENSORS_AVAILABLE = False
35
+
36
+ # VoxCPM2 modules reused verbatim (state-dict key prefixes).
37
+ VOXCPM2_REUSED_PREFIXES = (
38
+ "feat_encoder.",
39
+ "residual_lm.",
40
+ "feat_decoder.",
41
+ "fsq_layer.",
42
+ "enc_to_lm_proj.",
43
+ "lm_to_dit_proj.",
44
+ "res_to_dit_proj.",
45
+ "fusion_concat_proj.",
46
+ "stop_proj.",
47
+ "stop_head.",
48
+ )
49
+
50
+
51
+ def _load_state_dict(path_dir: str, stem: str) -> dict:
52
+ st_path = os.path.join(path_dir, f"{stem}.safetensors")
53
+ pt_candidates = [os.path.join(path_dir, f"{stem}.pth"), os.path.join(path_dir, f"{stem}.bin")]
54
+ if stem == "model":
55
+ pt_candidates.insert(0, os.path.join(path_dir, "pytorch_model.bin"))
56
+ if os.path.exists(st_path) and SAFETENSORS_AVAILABLE:
57
+ return load_file(st_path, device="cpu")
58
+ for p in pt_candidates:
59
+ if os.path.exists(p):
60
+ ckpt = torch.load(p, map_location="cpu", weights_only=True)
61
+ return ckpt.get("state_dict", ckpt)
62
+ raise FileNotFoundError(f"No checkpoint found for '{stem}' under {path_dir}")
63
+
64
+
65
+ def build_config_from_voxcpm2(
66
+ voxcpm2_path: str,
67
+ barbet_config_kwargs: dict,
68
+ adapter_config: Optional[AdapterConfig] = None,
69
+ **overrides,
70
+ ) -> BlueMagpieConfig:
71
+ """Derive a BlueMagpieConfig from a local VoxCPM2 checkpoint directory."""
72
+ with open(os.path.join(voxcpm2_path, "config.json"), "r", encoding="utf-8") as f:
73
+ vox_cfg = VoxCPMConfig.model_validate_json(f.read())
74
+
75
+ cfg = BlueMagpieConfig(
76
+ barbet_config=barbet_config_kwargs,
77
+ vox_lm_config=vox_cfg.lm_config,
78
+ patch_size=vox_cfg.patch_size,
79
+ feat_dim=vox_cfg.feat_dim,
80
+ residual_lm_num_layers=vox_cfg.residual_lm_num_layers,
81
+ residual_lm_no_rope=vox_cfg.residual_lm_no_rope,
82
+ scalar_quantization_latent_dim=vox_cfg.scalar_quantization_latent_dim,
83
+ scalar_quantization_scale=vox_cfg.scalar_quantization_scale,
84
+ encoder_config=vox_cfg.encoder_config,
85
+ dit_config=vox_cfg.dit_config,
86
+ audio_vae_config=vox_cfg.audio_vae_config,
87
+ adapter_config=adapter_config or AdapterConfig(),
88
+ max_length=vox_cfg.max_length,
89
+ device=vox_cfg.device,
90
+ dtype=vox_cfg.dtype,
91
+ **overrides,
92
+ )
93
+ return cfg
94
+
95
+
96
+ def load_barbet_config_kwargs(barbet_path: str) -> dict:
97
+ """Read a Barbet HF checkpoint's config.json into plain kwargs."""
98
+ with open(os.path.join(barbet_path, "config.json"), "r", encoding="utf-8") as f:
99
+ kwargs = json.load(f)
100
+ for k in ("architectures", "auto_map", "model_type", "transformers_version", "torch_dtype"):
101
+ kwargs.pop(k, None)
102
+ return kwargs
103
+
104
+
105
+ def build_from_pretrained(
106
+ voxcpm2_path: str,
107
+ barbet_path: Optional[str] = None,
108
+ barbet_config_kwargs: Optional[dict] = None,
109
+ tokenizer=None,
110
+ adapter_config: Optional[AdapterConfig] = None,
111
+ device: str | None = None,
112
+ training: bool = True,
113
+ **config_overrides,
114
+ ) -> BlueMagpieModel:
115
+ """Assemble a BlueMagpieModel from pretrained VoxCPM2 + Barbet checkpoints.
116
+
117
+ Args:
118
+ voxcpm2_path: local directory of the VoxCPM2 checkpoint
119
+ (e.g. ``huggingface_hub.snapshot_download("openbmb/VoxCPM2")``).
120
+ barbet_path: local directory of a Barbet checkpoint. If None, the TSLM
121
+ is randomly initialized from ``barbet_config_kwargs``.
122
+ barbet_config_kwargs: Barbet config kwargs; defaults to the checkpoint's
123
+ config.json (if ``barbet_path`` given) or BarbetConfig defaults.
124
+ tokenizer: HF tokenizer matching Barbet's vocab. Defaults to
125
+ ``AutoTokenizer.from_pretrained(barbet_path)`` when available.
126
+ adapter_config: projection adapter hyperparameters.
127
+ training: if True, returns a float32 trainable model (VAE frozen);
128
+ if False, casts to the configured inference dtype and eval().
129
+ """
130
+ if barbet_config_kwargs is None:
131
+ if barbet_path is not None:
132
+ barbet_config_kwargs = load_barbet_config_kwargs(barbet_path)
133
+ else:
134
+ barbet_config_kwargs = {}
135
+
136
+ config = build_config_from_voxcpm2(
137
+ voxcpm2_path, barbet_config_kwargs, adapter_config=adapter_config, **config_overrides
138
+ )
139
+
140
+ if tokenizer is None and barbet_path is not None:
141
+ try:
142
+ from transformers import AutoTokenizer
143
+
144
+ tokenizer = AutoTokenizer.from_pretrained(barbet_path)
145
+ except Exception:
146
+ print("[bluemagpie] no tokenizer found at barbet_path; attach one manually", file=sys.stderr)
147
+
148
+ # Audio VAE from the VoxCPM2 checkpoint
149
+ audio_vae = AudioVAEV2(config=config.audio_vae_config) if config.audio_vae_config else AudioVAEV2()
150
+ vae_state = _load_state_dict(voxcpm2_path, "audiovae")
151
+ audio_vae.load_state_dict(vae_state)
152
+
153
+ model = BlueMagpieModel(config, tokenizer, audio_vae, device=device)
154
+
155
+ # ---- VoxCPM2 weight surgery (drop base_lm.*) ---- #
156
+ vox_state = _load_state_dict(voxcpm2_path, "model")
157
+ kept = {k: v for k, v in vox_state.items() if k.startswith(VOXCPM2_REUSED_PREFIXES)}
158
+ dropped = len(vox_state) - len(kept)
159
+ missing, unexpected = model.load_state_dict(kept, strict=False)
160
+ print(
161
+ f"[bluemagpie] VoxCPM2 surgery: kept {len(kept)} tensors, dropped {dropped} (base_lm.* etc.), "
162
+ f"unexpected {len(unexpected)}",
163
+ file=sys.stderr,
164
+ )
165
+
166
+ # ---- Barbet TSLM weights ---- #
167
+ if barbet_path is not None:
168
+ load_barbet_weights(model, barbet_path)
169
+
170
+ model.audio_vae = model.audio_vae.to(torch.float32)
171
+ for name, param in model.named_parameters():
172
+ if name.startswith("audio_vae."):
173
+ param.requires_grad = False
174
+
175
+ if not training:
176
+ from bluemagpie._vendor.voxcpm.model.utils import get_dtype
177
+
178
+ non_vae = [m for n, m in model.named_children() if n != "audio_vae"]
179
+ for m in non_vae:
180
+ m.to(get_dtype(model.config.dtype))
181
+ model = model.to(model.device).eval()
182
+ else:
183
+ model = model.to(model.device)
184
+ return model
185
+
186
+
187
+ def load_barbet_weights(model: BlueMagpieModel, barbet_path: str) -> None:
188
+ """Load Barbet checkpoint into model.base_lm.backbone.
189
+
190
+ Accepts both BarbetModel and BarbetForCausalLM state dicts (the ``model.``
191
+ prefix is stripped; ``lm_head``/``mtp`` keys are dropped — R2 checkpoints
192
+ tie lm_head to the embeddings anyway). The embedding rows of the resolved
193
+ TTS special tokens are always re-initialized from N(0, initializer_range):
194
+ for R2 checkpoints those ids sit in the Megatron padding region, whose
195
+ checkpoint rows are typically all-zero and would otherwise make
196
+ audio_start/end/ref markers indistinguishable.
197
+ """
198
+ state = _load_state_dict(barbet_path, "model")
199
+ cleaned = {}
200
+ for k, v in state.items():
201
+ if k.startswith("lm_head.") or k.startswith("mtp."):
202
+ continue
203
+ cleaned[k.removeprefix("model.")] = v
204
+
205
+ backbone = model.base_lm.backbone
206
+ target_embed = backbone.embed_tokens.weight
207
+ src_embed = cleaned.get("embed_tokens.weight")
208
+ if src_embed is not None and src_embed.shape[0] != target_embed.shape[0]:
209
+ n_src = src_embed.shape[0]
210
+ grown = target_embed.detach().clone()
211
+ grown[:n_src] = src_embed
212
+ cleaned["embed_tokens.weight"] = grown
213
+ print(
214
+ f"[bluemagpie] resized Barbet embeddings {n_src} -> {target_embed.shape[0]}",
215
+ file=sys.stderr,
216
+ )
217
+
218
+ missing, unexpected = backbone.load_state_dict(cleaned, strict=False)
219
+ print(
220
+ f"[bluemagpie] Barbet load: missing {len(missing)}, unexpected {len(unexpected)}",
221
+ file=sys.stderr,
222
+ )
223
+
224
+ special_ids = [
225
+ model.audio_start_token,
226
+ model.audio_end_token,
227
+ model.ref_audio_start_token,
228
+ model.ref_audio_end_token,
229
+ model.spk_token,
230
+ ]
231
+ std = model.barbet_config.initializer_range
232
+ with torch.no_grad():
233
+ fresh = torch.empty(len(special_ids), target_embed.shape[1], dtype=target_embed.dtype)
234
+ fresh.normal_(mean=0.0, std=std)
235
+ backbone.embed_tokens.weight[special_ids] = fresh.to(target_embed.device)
236
+ print(f"[bluemagpie] re-initialized special token rows {special_ids}", file=sys.stderr)
237
+
238
+
239
+ # ---------------------------------------------------------------------- #
240
+ # Stage-wise freezing
241
+ # ---------------------------------------------------------------------- #
242
+ STAGES = ("bridge", "tslm", "full")
243
+
244
+
245
+ def set_training_stage(model: BlueMagpieModel, stage: str) -> None:
246
+ """Configure requires_grad for the staged training recipe.
247
+
248
+ - ``bridge``: only the new bridge modules train (enc_to_tslm_proj +
249
+ tslm_adapter). Use with hidden-space distillation against the original
250
+ VoxCPM2 TSLM, or with the diffusion loss directly.
251
+ - ``tslm``: bridge + Barbet + speaker projector train; the VoxCPM2
252
+ acoustic stack stays frozen (RALM / LocDiT keep handling acoustic detail
253
+ unchanged). This is where v1 speaker conditioning is learned.
254
+ - ``full``: everything trains except the AudioVAE.
255
+ """
256
+ if stage not in STAGES:
257
+ raise ValueError(f"stage must be one of {STAGES}, got {stage!r}")
258
+
259
+ bridge_prefixes = ("enc_to_tslm_proj.", "tslm_adapter.")
260
+ tslm_prefixes = bridge_prefixes + ("base_lm.", "speaker_projector.")
261
+
262
+ for name, param in model.named_parameters():
263
+ if name.startswith("audio_vae."):
264
+ param.requires_grad = False
265
+ elif stage == "bridge":
266
+ param.requires_grad = name.startswith(bridge_prefixes)
267
+ elif stage == "tslm":
268
+ param.requires_grad = name.startswith(tslm_prefixes)
269
+ else: # full
270
+ param.requires_grad = True
271
+
272
+
273
+ def load_voxcpm2_teacher(voxcpm2_path: str, device: str | None = None):
274
+ """Load the original VoxCPM2 model as a frozen distillation teacher.
275
+
276
+ Its pre-FSQ TSLM hidden states (``enc_outputs`` before the fsq/text mix)
277
+ are the target for aligning ``tslm_adapter`` outputs in stage ``bridge``.
278
+ """
279
+ from bluemagpie._vendor.voxcpm.model.voxcpm2 import VoxCPM2Model
280
+
281
+ teacher = VoxCPM2Model.from_local(voxcpm2_path, optimize=False, training=False, device=device)
282
+ for p in teacher.parameters():
283
+ p.requires_grad = False
284
+ return teacher
bluemagpie/model.py ADDED
@@ -0,0 +1,717 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BlueMagpie-TTS hybrid model.
2
+
3
+ Architecture = VoxCPM2 with its Text-Semantic LM (MiniCPM4) swapped for Barbet:
4
+
5
+ text tokens ──Barbet.embed_tokens──────────────┐
6
+ ├─ interleave ─> Barbet backbone ─> H_b
7
+ audio latents ─LocEnc─┬─ enc_to_tslm_proj(H_b)─┘ │
8
+ └─ enc_to_lm_proj(H_v)──────────┐ tslm_adapter (H_b -> H_v)
9
+ │ │
10
+ │ FSQ (audio positions)
11
+ │ │ enc_outputs (H_v)
12
+ fusion_concat_proj(cat) ┴─────────────┤
13
+ │ │
14
+ RALM (MiniCPM4 8L) lm_to_dit_proj
15
+ │ res_to_dit_proj │
16
+ └───────── concat ──────────┘
17
+ │ mu (2 prefix tokens)
18
+ LocDiT (CFM)
19
+ │ latent patch
20
+ AudioVAE
21
+
22
+ Division of labour (matching VoxCPM2's design intent):
23
+ - Barbet (TSLM): what to say, prosody planning, pacing, emphasis, control text.
24
+ - RALM + LocDiT: fine-grained acoustic detail, kept verbatim from VoxCPM2 so
25
+ pretrained weights load unchanged.
26
+ - ``tslm_adapter`` is the explicit bridge between the two incompatible hidden
27
+ spaces; ``enc_to_tslm_proj`` is its input-side counterpart feeding LocEnc
28
+ features into Barbet.
29
+
30
+ Everything outside the TSLM block mirrors
31
+ ``voxcpm/model/voxcpm2.py`` (Apache-2.0, Copyright 2026 OpenBMB) so that
32
+ VoxCPM2 checkpoints remain loadable.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import sys
39
+ import warnings
40
+ from typing import Generator, List, Optional, Tuple, Union
41
+
42
+ import librosa
43
+ import torch
44
+ import torch.nn as nn
45
+ from einops import rearrange
46
+ from tqdm import tqdm
47
+
48
+ from bluemagpie._vendor.voxcpm.model.utils import get_dtype, next_and_close, pick_runtime_dtype, resolve_runtime_device
49
+ from bluemagpie._vendor.voxcpm.modules.audiovae import AudioVAEV2
50
+ from bluemagpie._vendor.voxcpm.modules.layers import ScalarQuantizationLayer
51
+ from bluemagpie._vendor.voxcpm.modules.locdit import UnifiedCFM, VoxCPMLocDiTV2
52
+ from bluemagpie._vendor.voxcpm.modules.locenc import VoxCPMLocEnc
53
+ from bluemagpie._vendor.voxcpm.modules.minicpm4 import MiniCPMModel
54
+
55
+ from .adapter import ProjectionAdapter
56
+ from .conditioning import SpeakerProjector
57
+ from .config import BlueMagpieConfig
58
+ from .tslm import BarbetTSLM
59
+
60
+
61
+ class BlueMagpieModel(nn.Module):
62
+ def __init__(
63
+ self,
64
+ config: BlueMagpieConfig,
65
+ tokenizer=None,
66
+ audio_vae: AudioVAEV2 = None,
67
+ device: str | None = None,
68
+ ):
69
+ super().__init__()
70
+ self.config = config
71
+ self.feat_dim = config.feat_dim
72
+ self.patch_size = config.patch_size
73
+ self.device = resolve_runtime_device(device, config.device)
74
+ self.config.device = self.device
75
+ resolved_dtype = pick_runtime_dtype(self.device, self.config.dtype)
76
+ if resolved_dtype != self.config.dtype:
77
+ print(
78
+ f"[bluemagpie] adjusted dtype {self.config.dtype} -> {resolved_dtype} for device {self.device}",
79
+ file=sys.stderr,
80
+ )
81
+ self.config.dtype = resolved_dtype
82
+
83
+ vox_lm = config.vox_lm_config
84
+ h_vox = vox_lm.hidden_size
85
+
86
+ # ---------------- Text-Semantic LM: Barbet ---------------- #
87
+ barbet_cfg, token_ids = config.resolve_barbet_config()
88
+ self.barbet_config = barbet_cfg
89
+ self.base_lm = BarbetTSLM(barbet_cfg)
90
+ h_barbet = barbet_cfg.hidden_size
91
+
92
+ self.text_tokenizer = tokenizer
93
+ self.audio_start_token = token_ids["audio_start"]
94
+ self.audio_end_token = token_ids["audio_end"]
95
+ self.ref_audio_start_token = token_ids["ref_audio_start"]
96
+ self.ref_audio_end_token = token_ids["ref_audio_end"]
97
+ self.spk_token = token_ids["spk"]
98
+
99
+ # ---------------- Residual Acoustic LM (VoxCPM2) ---------------- #
100
+ residual_lm_config = vox_lm.model_copy(deep=True)
101
+ residual_lm_config.num_hidden_layers = config.residual_lm_num_layers
102
+ residual_lm_config.vocab_size = 0
103
+ residual_lm_config.no_rope = config.residual_lm_no_rope
104
+ self.residual_lm = MiniCPMModel(residual_lm_config)
105
+ self.residual_lm.setup_cache(1, config.max_length, self.device, get_dtype(self.config.dtype))
106
+
107
+ # ---------------- Local Encoder (VoxCPM2) ---------------- #
108
+ encoder_config = vox_lm.model_copy(deep=True)
109
+ encoder_config.hidden_size = config.encoder_config.hidden_dim
110
+ encoder_config.intermediate_size = config.encoder_config.ffn_dim
111
+ encoder_config.num_attention_heads = config.encoder_config.num_heads
112
+ encoder_config.num_hidden_layers = config.encoder_config.num_layers
113
+ encoder_config.kv_channels = config.encoder_config.kv_channels
114
+ encoder_config.vocab_size = 0
115
+ self.feat_encoder = VoxCPMLocEnc(encoder_config, input_dim=config.feat_dim)
116
+
117
+ # ---------------- Local DiT (VoxCPM2) ---------------- #
118
+ decoder_config = vox_lm.model_copy(deep=True)
119
+ decoder_config.hidden_size = config.dit_config.hidden_dim
120
+ decoder_config.intermediate_size = config.dit_config.ffn_dim
121
+ decoder_config.num_attention_heads = config.dit_config.num_heads
122
+ decoder_config.num_hidden_layers = config.dit_config.num_layers
123
+ decoder_config.kv_channels = config.dit_config.kv_channels
124
+ decoder_config.vocab_size = 0
125
+ self.feat_decoder = UnifiedCFM(
126
+ in_channels=config.feat_dim,
127
+ cfm_params=config.dit_config.cfm_config,
128
+ estimator=VoxCPMLocDiTV2(decoder_config, in_channels=config.feat_dim),
129
+ mean_mode=config.dit_config.dit_mean_mode,
130
+ )
131
+
132
+ # ---------------- VoxCPM2 projections (semantic space H_v) ---------------- #
133
+ self.fsq_layer = ScalarQuantizationLayer(
134
+ h_vox, h_vox, config.scalar_quantization_latent_dim, config.scalar_quantization_scale
135
+ )
136
+ self.enc_to_lm_proj = nn.Linear(config.encoder_config.hidden_dim, h_vox)
137
+ self.lm_to_dit_proj = nn.Linear(h_vox, config.dit_config.hidden_dim)
138
+ self.res_to_dit_proj = nn.Linear(h_vox, config.dit_config.hidden_dim)
139
+ self.fusion_concat_proj = nn.Linear(h_vox * 2, h_vox)
140
+
141
+ # Stop Predictor (semantic space)
142
+ self.stop_proj = nn.Linear(h_vox, h_vox)
143
+ self.stop_actn = nn.SiLU()
144
+ self.stop_head = nn.Linear(h_vox, 2, bias=False)
145
+ self.stop_loss = nn.CrossEntropyLoss(reduction="none")
146
+
147
+ # ---------------- Barbet <-> VoxCPM2 bridges (new, trained from scratch) -------- #
148
+ self.enc_to_tslm_proj = nn.Linear(config.encoder_config.hidden_dim, h_barbet)
149
+ self.tslm_adapter = ProjectionAdapter(h_barbet, h_vox, config.adapter_config)
150
+
151
+ # ---------------- Speaker conditioning (v1: centroid -> [spk] slot) ------------ #
152
+ self.speaker_embed_dim = config.speaker_embed_dim
153
+ self.speaker_projector = SpeakerProjector(config.speaker_embed_dim, h_barbet)
154
+
155
+ # ---------------- Audio VAE ---------------- #
156
+ self.audio_vae = audio_vae
157
+ if audio_vae is not None:
158
+ self.chunk_size = audio_vae.chunk_size
159
+ self._decode_chunk_size = getattr(audio_vae, "decode_chunk_size", audio_vae.chunk_size)
160
+ self._encode_sample_rate = audio_vae.sample_rate
161
+ self.sample_rate = getattr(audio_vae, "out_sample_rate", audio_vae.sample_rate)
162
+
163
+ def _dtype(self):
164
+ return get_dtype(self.config.dtype)
165
+
166
+ def _runtime_dtype(self) -> torch.dtype:
167
+ """Return the dtype the non-AudioVAE network is currently using."""
168
+ return next(self.parameters()).dtype
169
+
170
+ def _runtime_device(self) -> torch.device:
171
+ """Return the device the module is actually placed on.
172
+
173
+ Distributed wrappers may move parameters after construction. Keep the
174
+ cached device string in sync so Triton-backed kernels launch on the
175
+ same CUDA device as their inputs.
176
+ """
177
+ device = next(self.parameters()).device
178
+ if device.type == "cuda" and device.index is not None and torch.cuda.current_device() != device.index:
179
+ torch.cuda.set_device(device)
180
+ device_str = str(device)
181
+ if device_str != str(self.device):
182
+ self.device = device_str
183
+ self.config.device = device_str
184
+ return device
185
+
186
+ def _inject_speaker(self, combined_embed, speaker_centroids, spk_mask):
187
+ """Add the projected speaker centroid at the [spk] position.
188
+
189
+ ``combined_embed``: [B, T, H_b]. ``speaker_centroids``: [B, D] (the
190
+ per-speaker ECAPA centroid). ``spk_mask``: [B, T], 1 only at the [spk]
191
+ slot of samples whose speaker is present (not dropped). Where
192
+ ``spk_mask`` is 0 the slot keeps its [spk] token embedding, which acts
193
+ as the learned null speaker.
194
+ """
195
+ if speaker_centroids is None or spk_mask is None:
196
+ return combined_embed
197
+ dtype = combined_embed.dtype
198
+ spk_vec = self.speaker_projector(speaker_centroids.to(combined_embed.device, dtype=dtype)) # [B, H_b]
199
+ spk_mask = spk_mask.to(combined_embed.device, dtype=dtype)
200
+ return combined_embed + spk_mask.unsqueeze(-1) * spk_vec.unsqueeze(1)
201
+
202
+ def _tokenize(self, text: str) -> List[int]:
203
+ if self.text_tokenizer is None:
204
+ raise ValueError("No tokenizer attached to BlueMagpieModel")
205
+ if hasattr(self.text_tokenizer, "encode"):
206
+ return self.text_tokenizer.encode(text, add_special_tokens=False)
207
+ return self.text_tokenizer(text)
208
+
209
+ # ------------------------------------------------------------------ #
210
+ # Training forward
211
+ # ------------------------------------------------------------------ #
212
+ def forward(
213
+ self,
214
+ text_tokens: torch.Tensor,
215
+ text_mask: torch.Tensor,
216
+ audio_feats: torch.Tensor,
217
+ audio_mask: torch.Tensor,
218
+ loss_mask: torch.Tensor,
219
+ position_ids: torch.Tensor,
220
+ labels: torch.Tensor,
221
+ *,
222
+ speaker_centroids: Optional[torch.Tensor] = None,
223
+ spk_mask: Optional[torch.Tensor] = None,
224
+ progress: float = 0.0,
225
+ sample_generate: bool = False,
226
+ sample_generate_timesteps: int = 10,
227
+ ):
228
+ del position_ids # not used (parity with VoxCPM2)
229
+
230
+ device = self._runtime_device()
231
+ text_tokens = text_tokens.to(device, dtype=torch.long)
232
+ text_mask = text_mask.to(device, dtype=self._dtype())
233
+ audio_feats = audio_feats.to(device, dtype=self._dtype())
234
+ audio_mask = audio_mask.to(device, dtype=self._dtype())
235
+ loss_mask = loss_mask.to(device, dtype=self._dtype())
236
+ labels = labels.to(device, dtype=torch.long)
237
+
238
+ B, T, P, D = audio_feats.shape
239
+ feat_locenc = self.feat_encoder(audio_feats)
240
+ feat_embed_tslm = self.enc_to_tslm_proj(feat_locenc) # Barbet input space
241
+ feat_embed_lm = self.enc_to_lm_proj(feat_locenc) # VoxCPM2 semantic space (RALM fusion)
242
+
243
+ text_embed = self.base_lm.embed_tokens(text_tokens)
244
+ combined_embed = text_mask.unsqueeze(-1) * text_embed + audio_mask.unsqueeze(-1) * feat_embed_tslm
245
+ combined_embed = self._inject_speaker(combined_embed, speaker_centroids, spk_mask)
246
+
247
+ barbet_hidden = self.base_lm(inputs_embeds=combined_embed)
248
+ tslm_hidden = self.tslm_adapter(barbet_hidden).to(self._dtype())
249
+
250
+ enc_outputs = self.fsq_layer(tslm_hidden) * audio_mask.unsqueeze(-1) + tslm_hidden * text_mask.unsqueeze(-1)
251
+ lm_hidden = torch.cat((torch.zeros_like(enc_outputs[:, 0:1, :]), enc_outputs[:, :-1, :]), dim=1)
252
+
253
+ residual_inputs = self.fusion_concat_proj(
254
+ torch.cat((enc_outputs, audio_mask.unsqueeze(-1) * feat_embed_lm), dim=-1)
255
+ )
256
+ residual_outputs, _ = self.residual_lm(inputs_embeds=residual_inputs, is_causal=True)
257
+ residual_outputs = residual_outputs.to(self._dtype())
258
+ residual_hidden = torch.cat(
259
+ (torch.zeros_like(residual_outputs[:, 0:1, :]), residual_outputs[:, :-1, :]),
260
+ dim=1,
261
+ )
262
+
263
+ dit_hidden = torch.cat((self.lm_to_dit_proj(lm_hidden), self.res_to_dit_proj(residual_hidden)), dim=-1)
264
+ dit_hidden = rearrange(dit_hidden, "b t c -> (b t) c")
265
+
266
+ target_dtype = self._dtype()
267
+ feat_gt = rearrange(audio_feats.to(target_dtype), "b t p d -> (b t) p d")
268
+ feat_cond = torch.cat(
269
+ (torch.zeros_like(audio_feats[:, 0:1, ...]), audio_feats[:, :-1, ...]),
270
+ dim=1,
271
+ )
272
+ feat_cond = rearrange(feat_cond.to(target_dtype), "b t p d -> (b t) p d")
273
+
274
+ loss_seq_mask = loss_mask.unsqueeze(-1).repeat(1, 1, self.patch_size)
275
+ loss_seq_mask = rearrange(loss_seq_mask, "b t p -> (b t) p 1").to(target_dtype)
276
+
277
+ diff_loss = self.feat_decoder.compute_loss(
278
+ feat_gt.transpose(1, 2).contiguous(),
279
+ dit_hidden,
280
+ cond=feat_cond.transpose(1, 2).contiguous(),
281
+ tgt_mask=loss_seq_mask.transpose(1, 2).contiguous(),
282
+ progress=progress,
283
+ )
284
+
285
+ stop_logits = self.stop_head(self.stop_actn(self.stop_proj(lm_hidden)))
286
+ stop_losses = self.stop_loss(stop_logits.transpose(1, 2), labels)
287
+ denom = torch.clamp(loss_mask.sum(), min=1.0)
288
+ stop_loss = (stop_losses * loss_mask).sum() / denom
289
+
290
+ feat_pred = None
291
+ if sample_generate:
292
+ feat_cond_for_sample = feat_cond.transpose(1, 2).contiguous()
293
+ feat_pred_seq = self.feat_decoder(
294
+ mu=dit_hidden,
295
+ patch_size=self.patch_size,
296
+ cond=feat_cond_for_sample,
297
+ n_timesteps=max(int(sample_generate_timesteps), 1),
298
+ )
299
+ feat_pred = rearrange(feat_pred_seq.transpose(1, 2), "(b t) p d -> b d (t p)", b=B, p=self.patch_size)
300
+
301
+ feat_gt_tensor = rearrange(feat_gt, "(b t) p d -> b d (t p)", b=B, p=self.patch_size)
302
+
303
+ return {
304
+ "loss/diff": diff_loss,
305
+ "loss/stop": stop_loss,
306
+ "feat_gt": feat_gt_tensor,
307
+ "feat_pred": feat_pred,
308
+ "stop_logits": stop_logits,
309
+ # Pre-FSQ adapted hidden states, for hidden-space distillation
310
+ # against the original VoxCPM2 TSLM (see README, stage 0).
311
+ "tslm_hidden": tslm_hidden,
312
+ }
313
+
314
+ # ------------------------------------------------------------------ #
315
+ # Input assembly (the four VoxCPM2 prompting modes)
316
+ # ------------------------------------------------------------------ #
317
+ def _encode_wav(
318
+ self,
319
+ wav_path: str,
320
+ padding_mode: str = "right",
321
+ ) -> torch.Tensor:
322
+ audio, _ = librosa.load(wav_path, sr=self._encode_sample_rate, mono=True)
323
+ audio = torch.from_numpy(audio).unsqueeze(0)
324
+ patch_len = self.patch_size * self.chunk_size
325
+ if audio.size(1) % patch_len != 0:
326
+ padding_size = patch_len - audio.size(1) % patch_len
327
+ pad = (padding_size, 0) if padding_mode == "left" else (0, padding_size)
328
+ audio = torch.nn.functional.pad(audio, pad)
329
+ device = self._runtime_device()
330
+ feat = self.audio_vae.encode(audio.to(device), self._encode_sample_rate).cpu()
331
+ return feat.view(self.audio_vae.latent_dim, -1, self.patch_size).permute(1, 2, 0)
332
+
333
+ def _make_ref_prefix(self, ref_feat: torch.Tensor, device: torch.device):
334
+ ref_len = ref_feat.size(0)
335
+ z1 = torch.zeros((1, self.patch_size, self.audio_vae.latent_dim), dtype=torch.float32, device=device)
336
+ tokens = torch.cat(
337
+ [
338
+ torch.tensor([self.ref_audio_start_token], dtype=torch.int32, device=device),
339
+ torch.zeros(ref_len, dtype=torch.int32, device=device),
340
+ torch.tensor([self.ref_audio_end_token], dtype=torch.int32, device=device),
341
+ ]
342
+ )
343
+ feats = torch.cat([z1, ref_feat, z1], dim=0)
344
+ t_mask = torch.cat(
345
+ [
346
+ torch.tensor([1], dtype=torch.int32),
347
+ torch.zeros(ref_len, dtype=torch.int32),
348
+ torch.tensor([1], dtype=torch.int32),
349
+ ]
350
+ ).to(device)
351
+ a_mask = torch.cat(
352
+ [
353
+ torch.tensor([0], dtype=torch.int32),
354
+ torch.ones(ref_len, dtype=torch.int32),
355
+ torch.tensor([0], dtype=torch.int32),
356
+ ]
357
+ ).to(device)
358
+ return tokens, feats, t_mask, a_mask
359
+
360
+ def _build_inputs(
361
+ self,
362
+ text: str,
363
+ ref_feat: Optional[torch.Tensor] = None,
364
+ prompt_feat: Optional[torch.Tensor] = None,
365
+ speaker_slot: str = "none",
366
+ ):
367
+ """Assemble (text_token, audio_feat, text_mask, audio_mask, spk_mask).
368
+
369
+ Layout: [spk?] [ref prefix?] [text + audio_start] [prompt audio?]
370
+
371
+ ``speaker_slot`` is "none", "null", or "centroid". The null slot keeps
372
+ the learned [spk] token embedding, matching speaker-dropout training.
373
+ The centroid slot is filled by the projected speaker vector.
374
+ """
375
+ if speaker_slot not in {"none", "null", "centroid"}:
376
+ raise ValueError(f"speaker_slot must be one of none/null/centroid, got {speaker_slot!r}")
377
+
378
+ text_token = torch.LongTensor(self._tokenize(text))
379
+ text_token = torch.cat(
380
+ [
381
+ text_token,
382
+ torch.tensor([self.audio_start_token], dtype=torch.long, device=text_token.device),
383
+ ],
384
+ dim=-1,
385
+ )
386
+ text_length = text_token.shape[0]
387
+ device = text_token.device
388
+
389
+ text_pad_feat = torch.zeros(
390
+ (text_length, self.patch_size, self.audio_vae.latent_dim),
391
+ dtype=torch.float32,
392
+ device=device,
393
+ )
394
+
395
+ tokens = [text_token]
396
+ feats = [text_pad_feat]
397
+ t_masks = [torch.ones(text_length, dtype=torch.int32, device=device)]
398
+ a_masks = [torch.zeros(text_length, dtype=torch.int32, device=device)]
399
+ s_masks = [torch.zeros(text_length, dtype=torch.int32, device=device)]
400
+
401
+ if ref_feat is not None:
402
+ ref_tokens, ref_feats, ref_t_mask, ref_a_mask = self._make_ref_prefix(ref_feat, device)
403
+ tokens.insert(0, ref_tokens.long())
404
+ feats.insert(0, ref_feats)
405
+ t_masks.insert(0, ref_t_mask)
406
+ a_masks.insert(0, ref_a_mask)
407
+ s_masks.insert(0, torch.zeros(ref_tokens.shape[0], dtype=torch.int32, device=device))
408
+
409
+ if speaker_slot != "none":
410
+ one_feat = torch.zeros((1, self.patch_size, self.audio_vae.latent_dim), dtype=torch.float32, device=device)
411
+ tokens.insert(0, torch.tensor([self.spk_token], dtype=torch.long, device=device))
412
+ feats.insert(0, one_feat)
413
+ t_masks.insert(
414
+ 0,
415
+ torch.ones(1, dtype=torch.int32, device=device)
416
+ if speaker_slot == "null"
417
+ else torch.zeros(1, dtype=torch.int32, device=device),
418
+ )
419
+ a_masks.insert(0, torch.zeros(1, dtype=torch.int32, device=device))
420
+ s_masks.insert(
421
+ 0,
422
+ torch.ones(1, dtype=torch.int32, device=device)
423
+ if speaker_slot == "centroid"
424
+ else torch.zeros(1, dtype=torch.int32, device=device),
425
+ )
426
+
427
+ if prompt_feat is not None:
428
+ prompt_len = prompt_feat.size(0)
429
+ tokens.append(torch.zeros(prompt_len, dtype=torch.long, device=device))
430
+ feats.append(prompt_feat)
431
+ t_masks.append(torch.zeros(prompt_len, dtype=torch.int32, device=device))
432
+ a_masks.append(torch.ones(prompt_len, dtype=torch.int32, device=device))
433
+ s_masks.append(torch.zeros(prompt_len, dtype=torch.int32, device=device))
434
+
435
+ return (
436
+ torch.cat(tokens),
437
+ torch.cat(feats, dim=0),
438
+ torch.cat(t_masks),
439
+ torch.cat(a_masks),
440
+ torch.cat(s_masks),
441
+ )
442
+
443
+ # ------------------------------------------------------------------ #
444
+ # Generation
445
+ # ------------------------------------------------------------------ #
446
+ def generate(self, *args, **kwargs) -> torch.Tensor:
447
+ return next_and_close(self._generate(*args, streaming=False, **kwargs))
448
+
449
+ def generate_streaming(self, *args, **kwargs) -> Generator[torch.Tensor, None, None]:
450
+ return self._generate(*args, streaming=True, **kwargs)
451
+
452
+ @torch.inference_mode()
453
+ def _generate(
454
+ self,
455
+ target_text: str,
456
+ prompt_text: str = "",
457
+ prompt_wav_path: str = "",
458
+ reference_wav_path: str = "",
459
+ speaker_centroid: Optional[torch.Tensor] = None,
460
+ min_len: int = 2,
461
+ max_len: int = 2000,
462
+ inference_timesteps: int = 10,
463
+ cfg_value: float = 2.0,
464
+ retry_badcase: bool = False,
465
+ retry_badcase_max_times: int = 3,
466
+ retry_badcase_ratio_threshold: float = 6.0,
467
+ use_null_speaker: bool = True,
468
+ streaming: bool = False,
469
+ streaming_prefix_len: int = 4,
470
+ ) -> Generator[torch.Tensor, None, None]:
471
+ if retry_badcase and streaming:
472
+ warnings.warn("Retry on bad cases is not supported in streaming mode, setting retry_badcase=False.")
473
+ retry_badcase = False
474
+
475
+ device = self._runtime_device()
476
+ ref_feat = self._encode_wav(reference_wav_path, padding_mode="right") if reference_wav_path else None
477
+ prompt_feat = self._encode_wav(prompt_wav_path, padding_mode="left") if prompt_wav_path else None
478
+ text = (prompt_text + target_text) if prompt_feat is not None else target_text
479
+
480
+ speaker_centroids = None
481
+ if speaker_centroid is not None:
482
+ speaker_centroids = speaker_centroid.reshape(1, -1).to(device, dtype=self._runtime_dtype())
483
+
484
+ speaker_slot = "centroid" if speaker_centroids is not None else ("null" if use_null_speaker else "none")
485
+ text_token, audio_feat, text_mask, audio_mask, spk_mask = self._build_inputs(
486
+ text, ref_feat, prompt_feat, speaker_slot=speaker_slot
487
+ )
488
+
489
+ text_token = text_token.unsqueeze(0).to(device)
490
+ text_mask = text_mask.unsqueeze(0).to(device, dtype=self._runtime_dtype())
491
+ audio_feat = audio_feat.unsqueeze(0).to(device, dtype=self._runtime_dtype())
492
+ audio_mask = audio_mask.unsqueeze(0).to(device, dtype=self._runtime_dtype())
493
+ spk_mask = spk_mask.unsqueeze(0).to(device, dtype=self._runtime_dtype())
494
+
495
+ target_text_length = len(self._tokenize(target_text))
496
+
497
+ retry_badcase_times = 0
498
+ while retry_badcase_times < retry_badcase_max_times:
499
+ inference_result = self._inference(
500
+ text_token,
501
+ text_mask,
502
+ audio_feat,
503
+ audio_mask,
504
+ min_len=min_len,
505
+ max_len=min(int(target_text_length * retry_badcase_ratio_threshold + 10), max_len),
506
+ inference_timesteps=inference_timesteps,
507
+ cfg_value=cfg_value,
508
+ speaker_centroids=speaker_centroids,
509
+ spk_mask=spk_mask,
510
+ streaming=streaming,
511
+ streaming_prefix_len=streaming_prefix_len,
512
+ )
513
+ if streaming:
514
+ with self.audio_vae.streaming_decode() as vae_dec:
515
+ for latent_pred, _, _ctx in inference_result:
516
+ decode_audio = vae_dec.decode_chunk(latent_pred.to(torch.float32))
517
+ yield decode_audio.squeeze(1).cpu()
518
+ break
519
+ else:
520
+ latent_pred, pred_audio_feat, context_len = next_and_close(inference_result)
521
+ if retry_badcase and pred_audio_feat.shape[0] >= target_text_length * retry_badcase_ratio_threshold:
522
+ print(
523
+ f" Badcase detected, audio_text_ratio={pred_audio_feat.shape[0] / target_text_length}, retrying...",
524
+ file=sys.stderr,
525
+ )
526
+ retry_badcase_times += 1
527
+ continue
528
+ break
529
+
530
+ if not streaming:
531
+ decode_audio = self.audio_vae.decode(latent_pred.to(torch.float32))
532
+ decode_patch_len = self.patch_size * self._decode_chunk_size
533
+ if context_len > 0:
534
+ decode_audio = decode_audio[..., decode_patch_len * context_len :].squeeze(1).cpu()
535
+ else:
536
+ decode_audio = decode_audio.squeeze(1).cpu()
537
+ yield decode_audio
538
+
539
+ # ------------------------------------------------------------------ #
540
+ # Core AR loop
541
+ # ------------------------------------------------------------------ #
542
+ @torch.inference_mode()
543
+ def _inference(
544
+ self,
545
+ text: torch.Tensor,
546
+ text_mask: torch.Tensor,
547
+ feat: torch.Tensor,
548
+ feat_mask: torch.Tensor,
549
+ min_len: int = 2,
550
+ max_len: int = 2000,
551
+ inference_timesteps: int = 10,
552
+ cfg_value: float = 2.0,
553
+ speaker_centroids: Optional[torch.Tensor] = None,
554
+ spk_mask: Optional[torch.Tensor] = None,
555
+ streaming: bool = False,
556
+ streaming_prefix_len: int = 4,
557
+ ) -> Generator[Tuple[torch.Tensor, Union[torch.Tensor, List[torch.Tensor]], int], None, None]:
558
+ device = self._runtime_device()
559
+ dtype = self._runtime_dtype()
560
+ text = text.to(device)
561
+ text_mask = text_mask.to(device, dtype=dtype)
562
+ feat = feat.to(device, dtype=dtype)
563
+ feat_mask = feat_mask.to(device, dtype=dtype)
564
+ if speaker_centroids is not None:
565
+ speaker_centroids = speaker_centroids.to(device, dtype=dtype)
566
+ if spk_mask is not None:
567
+ spk_mask = spk_mask.to(device, dtype=dtype)
568
+ B, T, P, D = feat.shape
569
+
570
+ feat_locenc = self.feat_encoder(feat) # [b, t, h_enc]
571
+ feat_embed_tslm = self.enc_to_tslm_proj(feat_locenc)
572
+ feat_embed_lm = self.enc_to_lm_proj(feat_locenc)
573
+
574
+ text_embed = self.base_lm.embed_tokens(text)
575
+ combined_embed = text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed_tslm
576
+ combined_embed = self._inject_speaker(combined_embed, speaker_centroids, spk_mask)
577
+
578
+ prefix_feat_cond = feat[:, -1, ...] # b, p, d
579
+ curr_embed = None
580
+
581
+ # Streaming context patches (continuation modes only)
582
+ has_continuation_audio = feat_mask[0, -1].item() == 1
583
+ context_len = 0
584
+ if has_continuation_audio:
585
+ audio_indices = feat_mask.squeeze(0).nonzero(as_tuple=True)[0]
586
+ context_len = min(streaming_prefix_len - 1, len(audio_indices))
587
+ last_audio_indices = audio_indices[-context_len:]
588
+ pred_feat_seq = list(feat[:, last_audio_indices, :, :].split(1, dim=1))
589
+ else:
590
+ pred_feat_seq = []
591
+
592
+ # --- TSLM prefill (Barbet stepwise cache) --- #
593
+ barbet_hidden_seq, tslm_state = self.base_lm.prefill(combined_embed)
594
+ tslm_hidden_seq = self.tslm_adapter(barbet_hidden_seq)
595
+
596
+ enc_outputs = (
597
+ self.fsq_layer(tslm_hidden_seq) * feat_mask.unsqueeze(-1) + tslm_hidden_seq * text_mask.unsqueeze(-1)
598
+ )
599
+ lm_hidden = enc_outputs[:, -1, :]
600
+
601
+ # --- RALM prefill (MiniCPM static KV cache) --- #
602
+ residual_enc_inputs = self.fusion_concat_proj(
603
+ torch.cat((enc_outputs, feat_mask.unsqueeze(-1) * feat_embed_lm), dim=-1)
604
+ )
605
+ residual_enc_outputs, residual_kv_cache_tuple = self.residual_lm(
606
+ inputs_embeds=residual_enc_inputs,
607
+ is_causal=True,
608
+ )
609
+ self.residual_lm.kv_cache.fill_caches(residual_kv_cache_tuple)
610
+ residual_hidden = residual_enc_outputs[:, -1, :]
611
+
612
+ for i in tqdm(range(max_len)):
613
+ dit_hidden_1 = self.lm_to_dit_proj(lm_hidden) # [b, h_dit]
614
+ dit_hidden_2 = self.res_to_dit_proj(residual_hidden) # [b, h_dit]
615
+ dit_hidden = torch.cat((dit_hidden_1, dit_hidden_2), dim=-1)
616
+
617
+ pred_feat = self.feat_decoder(
618
+ mu=dit_hidden,
619
+ patch_size=self.patch_size,
620
+ cond=prefix_feat_cond.transpose(1, 2).contiguous(),
621
+ n_timesteps=inference_timesteps,
622
+ cfg_value=cfg_value,
623
+ ).transpose(1, 2) # [b, p, d]
624
+
625
+ curr_locenc = self.feat_encoder(pred_feat.unsqueeze(1)) # b, 1, h_enc
626
+ curr_embed_tslm = self.enc_to_tslm_proj(curr_locenc)
627
+ curr_embed_lm = self.enc_to_lm_proj(curr_locenc)
628
+ curr_embed = curr_embed_tslm # naming parity with VoxCPM2
629
+
630
+ pred_feat_seq.append(pred_feat.unsqueeze(1)) # b, 1, p, d
631
+ prefix_feat_cond = pred_feat
632
+
633
+ if streaming:
634
+ feat_pred = rearrange(pred_feat.unsqueeze(1), "b t p d -> b d (t p)", b=B, p=self.patch_size)
635
+ yield feat_pred, pred_feat_seq, context_len
636
+ if len(pred_feat_seq) > streaming_prefix_len:
637
+ pred_feat_seq = pred_feat_seq[-streaming_prefix_len:]
638
+
639
+ stop_flag = self.stop_head(self.stop_actn(self.stop_proj(lm_hidden))).argmax(dim=-1)[0].cpu().item()
640
+ if i > min_len and stop_flag == 1:
641
+ break
642
+
643
+ barbet_step_hidden = self.base_lm.forward_step(curr_embed[:, 0, :], tslm_state)
644
+ lm_hidden = self.fsq_layer(self.tslm_adapter(barbet_step_hidden))
645
+
646
+ curr_residual_input = self.fusion_concat_proj(torch.cat((lm_hidden, curr_embed_lm[:, 0, :]), dim=-1))
647
+ residual_hidden = self.residual_lm.forward_step(
648
+ curr_residual_input,
649
+ torch.tensor([self.residual_lm.kv_cache.step()], device=curr_residual_input.device),
650
+ ).clone()
651
+
652
+ if not streaming:
653
+ pred_feat_seq = torch.cat(pred_feat_seq, dim=1) # b, t, p, d
654
+ feat_pred = rearrange(pred_feat_seq, "b t p d -> b d (t p)", b=B, p=self.patch_size)
655
+ generated_feat = pred_feat_seq[:, context_len:, :, :].squeeze(0).cpu()
656
+ yield feat_pred, generated_feat, context_len
657
+
658
+ def inference(self, *args, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]:
659
+ feat_pred, generated_feat, _ = next_and_close(self._inference(*args, streaming=False, **kwargs))
660
+ return feat_pred, generated_feat
661
+
662
+ # ------------------------------------------------------------------ #
663
+ # Checkpoint I/O (BlueMagpie's own format)
664
+ # ------------------------------------------------------------------ #
665
+ def save_pretrained(self, path: str):
666
+ os.makedirs(path, exist_ok=True)
667
+ with open(os.path.join(path, "config.json"), "w", encoding="utf-8") as f:
668
+ f.write(self.config.model_dump_json(indent=2))
669
+ state = {k: v for k, v in self.state_dict().items() if not k.startswith("audio_vae.")}
670
+ torch.save(state, os.path.join(path, "pytorch_model.bin"))
671
+ if self.audio_vae is not None:
672
+ torch.save(self.audio_vae.state_dict(), os.path.join(path, "audiovae.pth"))
673
+ if self.text_tokenizer is not None and hasattr(self.text_tokenizer, "save_pretrained"):
674
+ self.text_tokenizer.save_pretrained(path)
675
+
676
+ @classmethod
677
+ def from_local(
678
+ cls,
679
+ path: str,
680
+ tokenizer=None,
681
+ training: bool = False,
682
+ device: str | None = None,
683
+ ) -> "BlueMagpieModel":
684
+ from bluemagpie._vendor.voxcpm.modules.audiovae import AudioVAEV2
685
+
686
+ with open(os.path.join(path, "config.json"), "r", encoding="utf-8") as f:
687
+ config = BlueMagpieConfig.model_validate_json(f.read())
688
+
689
+ if tokenizer is None:
690
+ try:
691
+ from transformers import AutoTokenizer
692
+
693
+ tokenizer = AutoTokenizer.from_pretrained(path)
694
+ except Exception:
695
+ tokenizer = None
696
+
697
+ audio_vae = AudioVAEV2(config=config.audio_vae_config) if config.audio_vae_config else AudioVAEV2()
698
+ vae_path = os.path.join(path, "audiovae.pth")
699
+ if os.path.exists(vae_path):
700
+ vae_state = torch.load(vae_path, map_location="cpu", weights_only=True)
701
+ audio_vae.load_state_dict(vae_state.get("state_dict", vae_state))
702
+
703
+ model = cls(config, tokenizer, audio_vae, device=device)
704
+ # mmap keeps the checkpoint memory-mapped instead of materializing a
705
+ # second full copy in RAM (load peak ~halved; matters on Colab-class hosts).
706
+ state = torch.load(
707
+ os.path.join(path, "pytorch_model.bin"), map_location="cpu", weights_only=True, mmap=True
708
+ )
709
+ missing, unexpected = model.load_state_dict(state, strict=False)
710
+ missing = [k for k in missing if not k.startswith("audio_vae.")]
711
+ if missing or unexpected:
712
+ print(f"[bluemagpie] missing keys: {missing[:8]}... unexpected: {unexpected[:8]}...", file=sys.stderr)
713
+
714
+ if not training:
715
+ model = model.to(get_dtype(model.config.dtype))
716
+ model.audio_vae = model.audio_vae.to(torch.float32)
717
+ return model.to(model.device).eval() if not training else model.to(model.device)
bluemagpie/tslm.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Barbet as the Text-Semantic LM (TSLM), with incremental decoding.
2
+
3
+ VoxCPM2's generation loop decodes one latent patch at a time, so the TSLM needs
4
+ a stateful single-step path. As of the open_formosa R2 architecture, Barbet
5
+ ships exactly that: a real **Mamba2** mixer and a native ``BarbetCache`` holding
6
+
7
+ - attention K/V states (rolling window for sliding-window layers),
8
+ - the trailing ``d_conv - 1`` causal-conv inputs per Mamba layer, and
9
+ - the Mamba2 selective-scan SSM state per Mamba layer.
10
+
11
+ We delegate stepwise decoding to it rather than re-deriving a cache by hand:
12
+ the selective-scan state cannot be reconstructed from a conv ring buffer alone,
13
+ so the official cache is the only correct option (and it stays in lockstep with
14
+ upstream). ``tests/test_step_equivalence.py`` asserts prefill + forward_step
15
+ reproduces the full-sequence forward across all layer types.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ import torch
23
+ from torch import nn
24
+
25
+ from barbet import BarbetConfig, BarbetModel
26
+ from barbet.modeling_barbet import BarbetCache
27
+
28
+
29
+ @dataclass
30
+ class BarbetStepState:
31
+ """Decoding state: Barbet's native hybrid cache (tracks position itself)."""
32
+
33
+ cache: BarbetCache
34
+
35
+ @property
36
+ def pos(self) -> int:
37
+ return self.cache.seen_tokens
38
+
39
+
40
+ class BarbetTSLM(nn.Module):
41
+ """Barbet backbone with embeddings, full forward, and cached stepwise decode."""
42
+
43
+ def __init__(self, config: BarbetConfig) -> None:
44
+ super().__init__()
45
+ self.config = config
46
+ self.backbone = BarbetModel(config)
47
+
48
+ @property
49
+ def embed_tokens(self) -> nn.Embedding:
50
+ return self.backbone.embed_tokens
51
+
52
+ @property
53
+ def hidden_size(self) -> int:
54
+ return self.config.hidden_size
55
+
56
+ def forward(
57
+ self,
58
+ inputs_embeds: torch.Tensor,
59
+ attention_mask: torch.Tensor | None = None,
60
+ ) -> torch.Tensor:
61
+ """Full-sequence forward (training / teacher-forcing), no cache."""
62
+ out = self.backbone(
63
+ inputs_embeds=inputs_embeds,
64
+ attention_mask=attention_mask,
65
+ use_cache=False,
66
+ return_dict=True,
67
+ )
68
+ return out.last_hidden_state
69
+
70
+ # ------------------------------------------------------------------ #
71
+ # Stepwise decoding (delegated to BarbetCache)
72
+ # ------------------------------------------------------------------ #
73
+ @torch.no_grad()
74
+ def prefill(self, inputs_embeds: torch.Tensor) -> tuple[torch.Tensor, BarbetStepState]:
75
+ """Run the prompt and return (hidden_states, state) with a warm cache."""
76
+ cache = BarbetCache(self.config)
77
+ out = self.backbone(
78
+ inputs_embeds=inputs_embeds,
79
+ past_key_values=cache,
80
+ use_cache=True,
81
+ return_dict=True,
82
+ )
83
+ return out.last_hidden_state, BarbetStepState(cache=out.past_key_values or cache)
84
+
85
+ @torch.no_grad()
86
+ def forward_step(self, x: torch.Tensor, state: BarbetStepState) -> torch.Tensor:
87
+ """Decode one position. ``x``: [B, H] input embedding -> [B, H] hidden.
88
+
89
+ The cache tracks the running position (``seen_tokens``), so RoPE offsets
90
+ and the sliding-window / conv / SSM states advance automatically.
91
+ """
92
+ out = self.backbone(
93
+ inputs_embeds=x.unsqueeze(1),
94
+ past_key_values=state.cache,
95
+ use_cache=True,
96
+ return_dict=True,
97
+ )
98
+ return out.last_hidden_state[:, 0, :]
production.py CHANGED
@@ -6,9 +6,13 @@ import math
6
  import random
7
  import re
8
  import unicodedata
 
 
 
9
 
10
  import numpy as np
11
  import torch
 
12
  from torch import nn
13
 
14
 
@@ -16,6 +20,18 @@ _SPACE_RE = re.compile(r"\s+")
16
  _PUNCT_NO_LEFT_SPACE_RE = re.compile(r"\s+([,。!?;:、,.!?;:])")
17
  _CJK_PUNCT_RIGHT_SPACE_RE = re.compile(r"([,。!?;:、])\s+")
18
  _BOPOMOFO_TONES = {"ˊ", "ˇ", "ˋ", "˙"}
 
 
 
 
 
 
 
 
 
 
 
 
19
  _TERMINAL_PUNCTUATION = frozenset("。!?.!?")
20
  _NONTERMINAL_TRAILING_PUNCTUATION = frozenset(",,、;;::")
21
  _TRAILING_CLOSERS = frozenset("\"'”’」』】))]}")
@@ -30,6 +46,127 @@ _BOPOMOFO_READINGS = {
30
  "ㄨ": "烏", "ㄩ": "迂",
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  class StopHysteresisController(nn.Module):
35
  """Apply probability-threshold hysteresis around a legacy stop head.
@@ -152,6 +289,262 @@ def _is_bopomofo(char: str) -> bool:
152
  return 0x3100 <= codepoint <= 0x312F or 0x31A0 <= codepoint <= 0x31BF or char in _BOPOMOFO_TONES
153
 
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  def normalize_tts_text(text: str) -> str:
156
  """Normalize common punctuation and pronounce standalone Bopomofo symbols."""
157
 
@@ -205,7 +598,13 @@ def normalize_tts_text(text: str) -> str:
205
  elif char in {";", ";"}:
206
  output.append(";" if has_cjk else ";")
207
  elif char in {":", ":"}:
208
- output.append(":" if following == "/" or previous.isdigit() and following.isdigit() else (":" if has_cjk else ":"))
 
 
 
 
 
 
209
  elif char in {"“", "”", "„", """}:
210
  output.append('"')
211
  elif char in {"‘", "’", "'"}:
@@ -224,6 +623,21 @@ def normalize_tts_text(text: str) -> str:
224
  return normalized.strip()
225
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  def ensure_terminal_punctuation(text: str) -> str:
228
  """Give the acoustic model an explicit endpoint cue without changing words."""
229
 
@@ -605,6 +1019,406 @@ def apply_loudness_floor(
605
  return output
606
 
607
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  @torch.no_grad()
609
  def extract_windowed_speaker_embedding(
610
  wav_path: str,
 
6
  import random
7
  import re
8
  import unicodedata
9
+ from dataclasses import dataclass
10
+ from datetime import date
11
+ from typing import Sequence
12
 
13
  import numpy as np
14
  import torch
15
+ from opencc import OpenCC
16
  from torch import nn
17
 
18
 
 
20
  _PUNCT_NO_LEFT_SPACE_RE = re.compile(r"\s+([,。!?;:、,.!?;:])")
21
  _CJK_PUNCT_RIGHT_SPACE_RE = re.compile(r"([,。!?;:、])\s+")
22
  _BOPOMOFO_TONES = {"ˊ", "ˇ", "ˋ", "˙"}
23
+ _BOPOMOFO_ASR_POPO_RE = re.compile(
24
+ r"(?i)(?<![a-z0-9])(?:po|bo)\s*(?:po|bo)\s*(?:mo|摸)(?![a-z0-9])"
25
+ )
26
+ _ASR_HOMOPHONE_TRANSLATION = str.maketrans(
27
+ {
28
+ "她": "他",
29
+ "它": "他",
30
+ "牠": "他",
31
+ "祂": "他",
32
+ "妳": "你",
33
+ }
34
+ )
35
  _TERMINAL_PUNCTUATION = frozenset("。!?.!?")
36
  _NONTERMINAL_TRAILING_PUNCTUATION = frozenset(",,、;;::")
37
  _TRAILING_CLOSERS = frozenset("\"'”’」』】))]}")
 
46
  "ㄨ": "烏", "ㄩ": "迂",
47
  }
48
 
49
+ _ZH_DIGITS = "零一二三四五六七八九"
50
+ _ZH_SMALL_UNITS = ("", "十", "百", "千")
51
+ _ZH_LARGE_UNITS = ("", "萬", "億", "兆")
52
+ _SPOKEN_DATE_RE = re.compile(
53
+ r"(?<![A-Za-z0-9/])(\d{4})([/-])(\d{1,2})\2(\d{1,2})(?![A-Za-z0-9/])"
54
+ )
55
+ _SPOKEN_ZH_DATE_RE = re.compile(
56
+ r"(?<![A-Za-z0-9])(\d{4})年(\d{1,2})月(\d{1,2})(?:日|號)(?![A-Za-z0-9])"
57
+ )
58
+ _SPOKEN_TIME_RE = re.compile(
59
+ r"(?<![A-Za-z0-9:])([01]?\d|2[0-3]):([0-5]\d)(?![A-Za-z0-9:])"
60
+ )
61
+ _SPOKEN_PERCENT_RE = re.compile(
62
+ r"(?<![A-Za-z0-9.])([+-]?\d+(?:\.\d+)?)\s*[%%](?![A-Za-z0-9])"
63
+ )
64
+ _SPOKEN_UNIT_RE = re.compile(
65
+ r"(?<![A-Za-z0-9.])([+-]?\d+(?:\.\d+)?)\s*"
66
+ r"(km/h|m/s|kHz|MHz|GHz|Hz|km|cm|mm|kg|mg|mL|ml|kW|°\s*[Cc]|℃|m|g|L|l|W|V)"
67
+ r"(?![A-Za-z])"
68
+ )
69
+ _SPOKEN_ZH_MEASURE_RE = re.compile(
70
+ r"(?<![A-Za-z0-9.])([+-]?\d+(?:\.\d+)?)\s*"
71
+ r"(公里|公尺|公分|公厘|厘米|毫米|公斤|千克|公克|毫克|公升|毫升|"
72
+ r"攝氏度|千赫茲|兆赫茲|吉赫茲|赫茲|小時|分鐘|米|克|升|度|秒|天|"
73
+ r"個|人|次|張|台|件|份|元)"
74
+ r"(?![A-Za-z0-9])"
75
+ )
76
+ _SPOKEN_URL_RE = re.compile(
77
+ r"(?i)(?<![A-Za-z0-9])(?:https?://|ftp://|www\.)[^\s<>\"',。!?;]+"
78
+ )
79
+ _SPOKEN_EMAIL_RE = re.compile(
80
+ r"(?i)(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])"
81
+ r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@"
82
+ r"[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+"
83
+ r"(?![A-Za-z0-9-])"
84
+ )
85
+ _SPOKEN_SEMVER_RE = re.compile(
86
+ r"(?i)(?<![A-Za-z0-9.])(?:v|version\s+|版本\s*(?:v\s*)?)?"
87
+ r"(\d+)\.(\d+)\.(\d+)"
88
+ r"(?:-([0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*))?"
89
+ r"(?:\+([0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*))?"
90
+ r"(?![A-Za-z0-9.])"
91
+ )
92
+ _CURRENCY_NUMBER_PATTERN = r"[+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?"
93
+ _CURRENCY_CODE_PATTERN = (
94
+ r"NT\$|TWD|NTD|US\$|USD|HK\$|HKD|CN¥|CNY|RMB|JPY|EUR|GBP|KRW|"
95
+ r"\$|€|¥|¥|£|₩"
96
+ )
97
+ _SPOKEN_CURRENCY_PREFIX_RE = re.compile(
98
+ rf"(?i)(?<![A-Za-z0-9])({_CURRENCY_CODE_PATTERN})\s*"
99
+ rf"({_CURRENCY_NUMBER_PATTERN})(?![\d,.])"
100
+ )
101
+ _SPOKEN_CURRENCY_SUFFIX_RE = re.compile(
102
+ rf"(?i)(?<![A-Za-z0-9.])({_CURRENCY_NUMBER_PATTERN})\s*"
103
+ rf"(TWD|NTD|USD|HKD|CNY|RMB|JPY|EUR|GBP|KRW)(?![A-Za-z])"
104
+ )
105
+ _MODEL_CODE_RE = re.compile(
106
+ r"(?<![A-Za-z0-9])([A-Z]{2,8})[ -]*(\d{1,8})(?![A-Za-z0-9.])"
107
+ )
108
+ _UPPERCASE_ACRONYM_RE = re.compile(r"(?<![A-Za-z0-9])([A-Z]{2,8})(?![A-Za-z0-9])")
109
+ _ZH_UNIT_READINGS = {
110
+ "km/h": "公里每小時",
111
+ "m/s": "公尺每秒",
112
+ "khz": "千赫茲",
113
+ "mhz": "兆赫茲",
114
+ "ghz": "吉赫茲",
115
+ "hz": "赫茲",
116
+ "km": "公里",
117
+ "cm": "公分",
118
+ "mm": "毫米",
119
+ "kg": "公斤",
120
+ "mg": "毫克",
121
+ "ml": "毫升",
122
+ "kw": "千瓦",
123
+ "°c": "攝氏度",
124
+ "℃": "攝氏度",
125
+ "m": "公尺",
126
+ "g": "公克",
127
+ "l": "公升",
128
+ "w": "瓦",
129
+ "v": "伏特",
130
+ }
131
+ _ZH_CURRENCY_READINGS = {
132
+ "NT$": "新台幣",
133
+ "TWD": "新台幣",
134
+ "NTD": "新台幣",
135
+ "US$": "美元",
136
+ "USD": "美元",
137
+ "$": "美元",
138
+ "HK$": "港幣",
139
+ "HKD": "港幣",
140
+ "CN¥": "人民幣",
141
+ "CNY": "人民幣",
142
+ "RMB": "人民幣",
143
+ "JPY": "日圓",
144
+ "¥": "日圓",
145
+ "¥": "日圓",
146
+ "EUR": "歐元",
147
+ "€": "歐元",
148
+ "GBP": "英鎊",
149
+ "£": "英鎊",
150
+ "KRW": "韓元",
151
+ "₩": "韓元",
152
+ }
153
+ _ZH_NETWORK_SYMBOL_READINGS = {
154
+ ".": "點",
155
+ "/": "斜線",
156
+ ":": "冒號",
157
+ "?": "問號",
158
+ "=": "等於",
159
+ "&": "和",
160
+ "#": "井號",
161
+ "@": "小老鼠",
162
+ "-": "橫線",
163
+ "_": "底線",
164
+ "%": "百分號",
165
+ "+": "加號",
166
+ "~": "波浪號",
167
+ }
168
+ _T2S_CONVERTER = OpenCC("t2s")
169
+
170
 
171
  class StopHysteresisController(nn.Module):
172
  """Apply probability-threshold hysteresis around a legacy stop head.
 
289
  return 0x3100 <= codepoint <= 0x312F or 0x31A0 <= codepoint <= 0x31BF or char in _BOPOMOFO_TONES
290
 
291
 
292
+ def _zh_four_digit_section(value: int, *, suppress_leading_one: bool = True) -> str:
293
+ """Read one non-negative, at-most-four-digit integer in Mandarin."""
294
+
295
+ output: list[str] = []
296
+ pending_zero = False
297
+ for position in range(3, -1, -1):
298
+ divisor = 10**position
299
+ digit = value // divisor
300
+ value %= divisor
301
+ if digit:
302
+ if pending_zero and output:
303
+ output.append(_ZH_DIGITS[0])
304
+ if not (digit == 1 and position == 1 and not output and suppress_leading_one):
305
+ output.append(_ZH_DIGITS[digit])
306
+ output.append(_ZH_SMALL_UNITS[position])
307
+ pending_zero = False
308
+ elif output and value:
309
+ pending_zero = True
310
+ return "".join(output)
311
+
312
+
313
+ def _zh_integer(value: int) -> str:
314
+ if value == 0:
315
+ return _ZH_DIGITS[0]
316
+ if value < 0 or value >= 10**16:
317
+ raise ValueError("Mandarin integer normalizer supports values from 0 to 10^16 - 1")
318
+
319
+ sections: list[int] = []
320
+ while value:
321
+ sections.append(value % 10000)
322
+ value //= 10000
323
+
324
+ output: list[str] = []
325
+ pending_zero = False
326
+ for section_index in range(len(sections) - 1, -1, -1):
327
+ section = sections[section_index]
328
+ if section == 0:
329
+ if output and any(sections[:section_index]):
330
+ pending_zero = True
331
+ continue
332
+ if output and (pending_zero or section < 1000):
333
+ output.append(_ZH_DIGITS[0])
334
+ output.append(_zh_four_digit_section(section, suppress_leading_one=not output))
335
+ output.append(_ZH_LARGE_UNITS[section_index])
336
+ pending_zero = False
337
+ return "".join(output)
338
+
339
+
340
+ def _zh_number(number: str) -> str:
341
+ """Read a validated decimal string as a Mandarin cardinal number."""
342
+
343
+ sign = ""
344
+ if number.startswith(("+", "-")):
345
+ sign = "正" if number[0] == "+" else "負"
346
+ number = number[1:]
347
+ integer, separator, fraction = number.partition(".")
348
+ if not integer or not integer.isdigit() or separator and (not fraction or not fraction.isdigit()):
349
+ raise ValueError("invalid decimal number")
350
+ if len(integer) > 16:
351
+ raise ValueError("number is too large for conservative normalization")
352
+ spoken = _zh_integer(int(integer))
353
+ if separator:
354
+ spoken += "點" + "".join(_ZH_DIGITS[int(digit)] for digit in fraction)
355
+ return sign + spoken
356
+
357
+
358
+ def _zh_digit_sequence(digits: str) -> str:
359
+ if not digits or not digits.isdigit():
360
+ raise ValueError("expected a non-empty digit sequence")
361
+ return "".join(_ZH_DIGITS[int(digit)] for digit in digits)
362
+
363
+
364
+ def _zh_network_text(value: str) -> str:
365
+ """Spell network identifiers while making separators audible."""
366
+
367
+ output: list[str] = []
368
+ buffer: list[str] = []
369
+
370
+ def flush() -> None:
371
+ if not buffer:
372
+ return
373
+ token = "".join(buffer)
374
+ if token.isdigit():
375
+ output.append(_zh_digit_sequence(token))
376
+ elif token.lower() == "www" or len(token) > 1 and token.isupper():
377
+ output.extend(token.upper())
378
+ else:
379
+ output.append(token)
380
+ buffer.clear()
381
+
382
+ for character in value:
383
+ if character.isascii() and character.isalnum():
384
+ if buffer and character.isdigit() != buffer[-1].isdigit():
385
+ flush()
386
+ buffer.append(character)
387
+ continue
388
+ flush()
389
+ reading = _ZH_NETWORK_SYMBOL_READINGS.get(character)
390
+ if reading:
391
+ output.append(reading)
392
+ elif not character.isspace():
393
+ output.append(character)
394
+ flush()
395
+ return " ".join(output)
396
+
397
+
398
+ def _zh_url(value: str) -> str:
399
+ scheme, separator, remainder = value.partition("://")
400
+ if separator:
401
+ protocol = " ".join(scheme.upper())
402
+ return f"{protocol} 冒號 斜線 斜線 {_zh_network_text(remainder)}"
403
+ return _zh_network_text(value)
404
+
405
+
406
+ def _zh_email(value: str) -> str:
407
+ local, domain = value.rsplit("@", 1)
408
+ return f"{_zh_network_text(local)} 小老鼠 {_zh_network_text(domain)}"
409
+
410
+
411
+ def _zh_semantic_version(match: re.Match[str]) -> str:
412
+ major, minor, patch, prerelease, build = match.groups()
413
+ try:
414
+ core = "點".join(_zh_integer(int(part)) for part in (major, minor, patch))
415
+ except ValueError:
416
+ return match.group(0)
417
+ output = f"版本{core}"
418
+ if prerelease:
419
+ output += f" 預發布 {_zh_network_text(prerelease)}"
420
+ if build:
421
+ output += f" 建置 {_zh_network_text(build)}"
422
+ return output
423
+
424
+
425
+ def _zh_currency(code: str, number: str, original: str) -> str:
426
+ reading = _ZH_CURRENCY_READINGS.get(code.upper(), _ZH_CURRENCY_READINGS.get(code))
427
+ if reading is None:
428
+ return original
429
+ try:
430
+ spoken_number = _zh_number(number.replace(",", ""))
431
+ except ValueError:
432
+ return original
433
+ return reading + spoken_number
434
+
435
+
436
+ def normalize_spoken_forms(text: str, *, locale: str = "zh-TW") -> str:
437
+ """Conservatively expand common written forms for TTS.
438
+
439
+ Chinese locales expand validated dates, network addresses, semantic
440
+ versions, currencies, percentages, numeric units, and standalone
441
+ all-uppercase acronyms/model identifiers. Ordinary English words are
442
+ deliberately left untouched. English locales only receive the existing
443
+ Unicode/punctuation normalization. Unknown locales raise instead of
444
+ silently applying the wrong pronunciation rules.
445
+ """
446
+
447
+ raw = unicodedata.normalize("NFC", str(text or ""))
448
+ locale_key = str(locale or "").replace("_", "-").lower()
449
+ if locale_key in {"en", "en-us", "en-gb"}:
450
+ return normalize_tts_text(raw)
451
+ if locale_key not in {"zh", "zh-tw", "zh-hant", "zh-cn", "zh-hans"}:
452
+ raise ValueError(f"unsupported spoken-form locale: {locale!r}")
453
+
454
+ protected: list[str] = []
455
+
456
+ def protect(spoken: str) -> str:
457
+ marker = f"\uf000{len(protected)}\uf001"
458
+ protected.append(spoken)
459
+ return marker
460
+
461
+ def replace_url(match: re.Match[str]) -> str:
462
+ matched = match.group(0)
463
+ core = matched.rstrip(".,!?;:")
464
+ trailing = matched[len(core) :]
465
+ return protect(_zh_url(core)) + trailing
466
+
467
+ def replace_email(match: re.Match[str]) -> str:
468
+ return protect(_zh_email(match.group(0)))
469
+
470
+ def replace_date(match: re.Match[str]) -> str:
471
+ year_text, _, month_text, day_text = match.groups()
472
+ try:
473
+ date(int(year_text), int(month_text), int(day_text))
474
+ except ValueError:
475
+ return match.group(0)
476
+ return (
477
+ f"{_zh_digit_sequence(year_text)}年"
478
+ f"{_zh_integer(int(month_text))}月{_zh_integer(int(day_text))}日"
479
+ )
480
+
481
+ def replace_zh_date(match: re.Match[str]) -> str:
482
+ year_text, month_text, day_text = match.groups()
483
+ try:
484
+ date(int(year_text), int(month_text), int(day_text))
485
+ except ValueError:
486
+ return match.group(0)
487
+ return (
488
+ f"{_zh_digit_sequence(year_text)}年"
489
+ f"{_zh_integer(int(month_text))}月{_zh_integer(int(day_text))}日"
490
+ )
491
+
492
+ def replace_number(match: re.Match[str], suffix: str) -> str:
493
+ try:
494
+ return _zh_number(match.group(1)) + suffix
495
+ except ValueError:
496
+ return match.group(0)
497
+
498
+ def replace_time(match: re.Match[str]) -> str:
499
+ hour = int(match.group(1))
500
+ minute = int(match.group(2))
501
+ minute_text = "整" if minute == 0 else f"{_zh_integer(minute)}分"
502
+ return f"{_zh_integer(hour)}點{minute_text}"
503
+
504
+ def replace_percent(match: re.Match[str]) -> str:
505
+ try:
506
+ return "百分之" + _zh_number(match.group(1))
507
+ except ValueError:
508
+ return match.group(0)
509
+
510
+ def replace_unit(match: re.Match[str]) -> str:
511
+ unit_key = re.sub(r"\s+", "", match.group(2)).lower()
512
+ reading = _ZH_UNIT_READINGS.get(unit_key)
513
+ if reading is None:
514
+ return match.group(0)
515
+ return replace_number(match, reading)
516
+
517
+ def replace_model_code(match: re.Match[str]) -> str:
518
+ letters, digits = match.groups()
519
+ return f"{' '.join(letters)} {_zh_digit_sequence(digits)}"
520
+
521
+ output = _SPOKEN_URL_RE.sub(replace_url, raw)
522
+ output = _SPOKEN_EMAIL_RE.sub(replace_email, output)
523
+ output = _SPOKEN_ZH_DATE_RE.sub(replace_zh_date, output)
524
+ output = _SPOKEN_DATE_RE.sub(replace_date, output)
525
+ output = _SPOKEN_TIME_RE.sub(replace_time, output)
526
+ output = _SPOKEN_PERCENT_RE.sub(replace_percent, output)
527
+ output = _SPOKEN_UNIT_RE.sub(replace_unit, output)
528
+ output = _SPOKEN_ZH_MEASURE_RE.sub(
529
+ lambda match: replace_number(match, match.group(2)),
530
+ output,
531
+ )
532
+ output = _SPOKEN_SEMVER_RE.sub(_zh_semantic_version, output)
533
+ output = _SPOKEN_CURRENCY_PREFIX_RE.sub(
534
+ lambda match: _zh_currency(match.group(1), match.group(2), match.group(0)),
535
+ output,
536
+ )
537
+ output = _SPOKEN_CURRENCY_SUFFIX_RE.sub(
538
+ lambda match: _zh_currency(match.group(2), match.group(1), match.group(0)),
539
+ output,
540
+ )
541
+ output = _MODEL_CODE_RE.sub(replace_model_code, output)
542
+ output = _UPPERCASE_ACRONYM_RE.sub(lambda match: " ".join(match.group(1)), output)
543
+ for index, spoken in enumerate(protected):
544
+ output = output.replace(f"\uf000{index}\uf001", spoken)
545
+ return normalize_tts_text(output)
546
+
547
+
548
  def normalize_tts_text(text: str) -> str:
549
  """Normalize common punctuation and pronounce standalone Bopomofo symbols."""
550
 
 
598
  elif char in {";", ";"}:
599
  output.append(";" if has_cjk else ";")
600
  elif char in {":", ":"}:
601
+ ascii_context = (
602
+ previous.isascii()
603
+ and previous.isalnum()
604
+ and following.isascii()
605
+ and following.isalnum()
606
+ )
607
+ output.append(":" if following == "/" or ascii_context else (":" if has_cjk else ":"))
608
  elif char in {"“", "”", "„", """}:
609
  output.append('"')
610
  elif char in {"‘", "’", "'"}:
 
623
  return normalized.strip()
624
 
625
 
626
+ def normalize_tts_eval_text(text: str) -> str:
627
+ """Normalize only verified ASR-equivalent forms for semantic scoring.
628
+
629
+ This remains separate from model-input normalization: Mandarin pronoun
630
+ homophones are acoustically indistinguishable, but their written forms must
631
+ stay untouched in the text sent to the TTS model.
632
+ """
633
+
634
+ normalized = normalize_tts_text(text)
635
+ normalized = normalized.translate(_ASR_HOMOPHONE_TRANSLATION)
636
+ if "注音" in normalized or "符號" in normalized:
637
+ normalized = _BOPOMOFO_ASR_POPO_RE.sub("波坡摸", normalized)
638
+ return normalized
639
+
640
+
641
  def ensure_terminal_punctuation(text: str) -> str:
642
  """Give the acoustic model an explicit endpoint cue without changing words."""
643
 
 
1019
  return output
1020
 
1021
 
1022
+ def _comparison_text(text: str, *, locale: str) -> str:
1023
+ normalized = normalize_tts_eval_text(
1024
+ normalize_spoken_forms(text, locale=locale)
1025
+ ).casefold()
1026
+ locale_key = str(locale or "").replace("_", "-").lower()
1027
+ if locale_key in {"zh", "zh-tw", "zh-hant", "zh-cn", "zh-hans"}:
1028
+ normalized = _T2S_CONVERTER.convert(normalized)
1029
+ return "".join(char for char in normalized if char.isalnum())
1030
+
1031
+
1032
+ def _levenshtein_alignment(source: str, hypothesis: str) -> list[tuple[str, int, int]]:
1033
+ """Return deterministic edit operations as ``(op, source_index, hyp_index)``."""
1034
+
1035
+ rows = len(source) + 1
1036
+ columns = len(hypothesis) + 1
1037
+ distance = [[0] * columns for _ in range(rows)]
1038
+ for source_index in range(rows):
1039
+ distance[source_index][0] = source_index
1040
+ for hyp_index in range(columns):
1041
+ distance[0][hyp_index] = hyp_index
1042
+ for source_index in range(1, rows):
1043
+ for hyp_index in range(1, columns):
1044
+ substitution = distance[source_index - 1][hyp_index - 1] + (
1045
+ source[source_index - 1] != hypothesis[hyp_index - 1]
1046
+ )
1047
+ deletion = distance[source_index - 1][hyp_index] + 1
1048
+ insertion = distance[source_index][hyp_index - 1] + 1
1049
+ distance[source_index][hyp_index] = min(substitution, deletion, insertion)
1050
+
1051
+ operations: list[tuple[str, int, int]] = []
1052
+ source_index = len(source)
1053
+ hyp_index = len(hypothesis)
1054
+ while source_index or hyp_index:
1055
+ if source_index and hyp_index:
1056
+ cost = source[source_index - 1] != hypothesis[hyp_index - 1]
1057
+ if distance[source_index][hyp_index] == distance[source_index - 1][hyp_index - 1] + cost:
1058
+ operations.append(
1059
+ (
1060
+ "substitute" if cost else "equal",
1061
+ source_index - 1,
1062
+ hyp_index - 1,
1063
+ )
1064
+ )
1065
+ source_index -= 1
1066
+ hyp_index -= 1
1067
+ continue
1068
+ if source_index and distance[source_index][hyp_index] == distance[source_index - 1][hyp_index] + 1:
1069
+ operations.append(("delete", source_index - 1, hyp_index))
1070
+ source_index -= 1
1071
+ continue
1072
+ operations.append(("insert", source_index, hyp_index - 1))
1073
+ hyp_index -= 1
1074
+ operations.reverse()
1075
+ return operations
1076
+
1077
+
1078
+ @dataclass(frozen=True)
1079
+ class AsrComparison:
1080
+ """Character-level semantic completion evidence for one TTS candidate."""
1081
+
1082
+ target_text: str
1083
+ transcript_text: str
1084
+ edit_distance: int
1085
+ cer: float
1086
+ prefix_cer: float
1087
+ suffix_cer: float
1088
+ prefix_deletions: int
1089
+ suffix_deletions: int
1090
+ extra_tail: str
1091
+ extra_tail_units: int
1092
+ passed: bool
1093
+
1094
+
1095
+ def compare_asr_text(
1096
+ target: str,
1097
+ transcript: str,
1098
+ *,
1099
+ locale: str = "zh-TW",
1100
+ prefix_units: int = 6,
1101
+ suffix_units: int = 6,
1102
+ max_cer: float = 0.10,
1103
+ max_prefix_cer: float = 0.0,
1104
+ max_suffix_cer: float = 0.0,
1105
+ max_extra_tail_units: int = 0,
1106
+ ) -> AsrComparison:
1107
+ """Compare an ASR transcript with strict onset, completion, and tail gates.
1108
+
1109
+ Punctuation and whitespace are ignored, while written dates/numbers are
1110
+ normalized before alignment. Empty targets, unsupported locales, invalid
1111
+ limits, and non-finite limits all fail closed via ``passed=False``.
1112
+ """
1113
+
1114
+ try:
1115
+ target_text = _comparison_text(target, locale=locale)
1116
+ transcript_text = _comparison_text(transcript, locale=locale)
1117
+ except (TypeError, ValueError):
1118
+ target_text = ""
1119
+ transcript_text = ""
1120
+
1121
+ try:
1122
+ prefix_count = int(prefix_units)
1123
+ suffix_count = int(suffix_units)
1124
+ except (TypeError, ValueError, OverflowError):
1125
+ prefix_count = 0
1126
+ suffix_count = 0
1127
+ prefix_limit = min(max(0, prefix_count), len(target_text))
1128
+ suffix_start = max(0, len(target_text) - max(0, suffix_count))
1129
+
1130
+ operations = _levenshtein_alignment(target_text, transcript_text)
1131
+ edit_distance = sum(operation != "equal" for operation, _, _ in operations)
1132
+ cer = edit_distance / len(target_text) if target_text else math.inf
1133
+ prefix_errors = 0
1134
+ suffix_errors = 0
1135
+ prefix_deletions = 0
1136
+ suffix_deletions = 0
1137
+ tail_characters: list[str] = []
1138
+ for operation, source_index, hyp_index in operations:
1139
+ if operation == "equal":
1140
+ continue
1141
+ if operation == "insert":
1142
+ if source_index < prefix_limit:
1143
+ prefix_errors += 1
1144
+ if source_index >= suffix_start:
1145
+ suffix_errors += 1
1146
+ if source_index >= len(target_text) and 0 <= hyp_index < len(transcript_text):
1147
+ tail_characters.append(transcript_text[hyp_index])
1148
+ continue
1149
+ if source_index < prefix_limit:
1150
+ prefix_errors += 1
1151
+ prefix_deletions += operation == "delete"
1152
+ if source_index >= suffix_start:
1153
+ suffix_errors += 1
1154
+ suffix_deletions += operation == "delete"
1155
+
1156
+ prefix_cer = prefix_errors / prefix_limit if prefix_limit else math.inf
1157
+ suffix_length = len(target_text) - suffix_start
1158
+ suffix_cer = suffix_errors / suffix_length if suffix_length else math.inf
1159
+ extra_tail = "".join(tail_characters)
1160
+
1161
+ limits: list[float] = []
1162
+ for value in (max_cer, max_prefix_cer, max_suffix_cer, max_extra_tail_units):
1163
+ try:
1164
+ limits.append(float(value))
1165
+ except (TypeError, ValueError, OverflowError):
1166
+ limits.append(math.nan)
1167
+ valid_limits = all(math.isfinite(value) and value >= 0.0 for value in limits)
1168
+ passed = bool(
1169
+ target_text
1170
+ and transcript_text
1171
+ and prefix_limit > 0
1172
+ and suffix_length > 0
1173
+ and valid_limits
1174
+ and cer <= limits[0]
1175
+ and prefix_cer <= limits[1]
1176
+ and suffix_cer <= limits[2]
1177
+ and len(extra_tail) <= limits[3]
1178
+ )
1179
+ return AsrComparison(
1180
+ target_text=target_text,
1181
+ transcript_text=transcript_text,
1182
+ edit_distance=edit_distance,
1183
+ cer=cer,
1184
+ prefix_cer=prefix_cer,
1185
+ suffix_cer=suffix_cer,
1186
+ prefix_deletions=prefix_deletions,
1187
+ suffix_deletions=suffix_deletions,
1188
+ extra_tail=extra_tail,
1189
+ extra_tail_units=len(extra_tail),
1190
+ passed=passed,
1191
+ )
1192
+
1193
+
1194
+ def _finite_number(value, *, minimum: float | None = None, maximum: float | None = None) -> float | None:
1195
+ if isinstance(value, (bool, np.bool_)):
1196
+ return None
1197
+ try:
1198
+ number = float(value)
1199
+ except (TypeError, ValueError, OverflowError):
1200
+ return None
1201
+ if not math.isfinite(number):
1202
+ return None
1203
+ if minimum is not None and number < minimum:
1204
+ return None
1205
+ if maximum is not None and number > maximum:
1206
+ return None
1207
+ return number
1208
+
1209
+
1210
+ def candidate_local_score(
1211
+ *,
1212
+ cer: float,
1213
+ speaker_similarity: float,
1214
+ boundary_speaker_drop: float,
1215
+ prefix_cer: float = 0.0,
1216
+ suffix_cer: float = 0.0,
1217
+ pace_penalty: float = 0.0,
1218
+ style_penalty: float = 0.0,
1219
+ asr_passed: bool = True,
1220
+ truncated: bool = False,
1221
+ extra_tail: bool = False,
1222
+ speaker_weight: float = 0.05,
1223
+ boundary_weight: float = 0.10,
1224
+ prefix_weight: float = 0.50,
1225
+ suffix_weight: float = 0.50,
1226
+ pace_weight: float = 1.0,
1227
+ style_weight: float = 1.0,
1228
+ max_cer: float | None = None,
1229
+ min_speaker_similarity: float | None = None,
1230
+ max_boundary_speaker_drop: float | None = None,
1231
+ ) -> float:
1232
+ """Return a finite local candidate cost or ``inf`` for unsafe evidence."""
1233
+
1234
+ if asr_passed is not True or truncated is not False or extra_tail is not False:
1235
+ return math.inf
1236
+ metric_values = [
1237
+ _finite_number(cer, minimum=0.0),
1238
+ _finite_number(speaker_similarity, minimum=-1.0, maximum=1.0),
1239
+ _finite_number(boundary_speaker_drop, minimum=0.0),
1240
+ _finite_number(prefix_cer, minimum=0.0),
1241
+ _finite_number(suffix_cer, minimum=0.0),
1242
+ _finite_number(pace_penalty, minimum=0.0),
1243
+ _finite_number(style_penalty, minimum=0.0),
1244
+ ]
1245
+ weights = [
1246
+ _finite_number(speaker_weight, minimum=0.0),
1247
+ _finite_number(boundary_weight, minimum=0.0),
1248
+ _finite_number(prefix_weight, minimum=0.0),
1249
+ _finite_number(suffix_weight, minimum=0.0),
1250
+ _finite_number(pace_weight, minimum=0.0),
1251
+ _finite_number(style_weight, minimum=0.0),
1252
+ ]
1253
+ if any(value is None for value in metric_values + weights):
1254
+ return math.inf
1255
+ candidate_cer, similarity, boundary_drop, onset_cer, ending_cer, pace, style = metric_values
1256
+
1257
+ gate_max_cer = None if max_cer is None else _finite_number(max_cer, minimum=0.0)
1258
+ gate_min_similarity = (
1259
+ None
1260
+ if min_speaker_similarity is None
1261
+ else _finite_number(min_speaker_similarity, minimum=-1.0, maximum=1.0)
1262
+ )
1263
+ gate_max_boundary = (
1264
+ None
1265
+ if max_boundary_speaker_drop is None
1266
+ else _finite_number(max_boundary_speaker_drop, minimum=0.0)
1267
+ )
1268
+ if (
1269
+ max_cer is not None and gate_max_cer is None
1270
+ or min_speaker_similarity is not None and gate_min_similarity is None
1271
+ or max_boundary_speaker_drop is not None and gate_max_boundary is None
1272
+ ):
1273
+ return math.inf
1274
+ if gate_max_cer is not None and candidate_cer > gate_max_cer:
1275
+ return math.inf
1276
+ if gate_min_similarity is not None and similarity < gate_min_similarity:
1277
+ return math.inf
1278
+ if gate_max_boundary is not None and boundary_drop > gate_max_boundary:
1279
+ return math.inf
1280
+
1281
+ speaker_w, boundary_w, prefix_w, suffix_w, pace_w, style_w = weights
1282
+ score = (
1283
+ candidate_cer
1284
+ + speaker_w * (1.0 - similarity)
1285
+ + boundary_w * boundary_drop
1286
+ + prefix_w * onset_cer
1287
+ + suffix_w * ending_cer
1288
+ + pace_w * pace
1289
+ + style_w * style
1290
+ )
1291
+ return score if math.isfinite(score) and score >= 0.0 else math.inf
1292
+
1293
+
1294
+ def candidate_transition_score(
1295
+ *,
1296
+ speaker_similarity: float,
1297
+ f0_delta: float,
1298
+ rms_delta: float,
1299
+ speaker_weight: float = 1.0,
1300
+ f0_weight: float = 1.0,
1301
+ rms_weight: float = 1.0,
1302
+ ) -> float:
1303
+ """Score continuity between adjacent candidates with finite-only inputs."""
1304
+
1305
+ values = [
1306
+ _finite_number(speaker_similarity, minimum=-1.0, maximum=1.0),
1307
+ _finite_number(f0_delta, minimum=0.0),
1308
+ _finite_number(rms_delta, minimum=0.0),
1309
+ _finite_number(speaker_weight, minimum=0.0),
1310
+ _finite_number(f0_weight, minimum=0.0),
1311
+ _finite_number(rms_weight, minimum=0.0),
1312
+ ]
1313
+ if any(value is None for value in values):
1314
+ return math.inf
1315
+ similarity, pitch_delta, loudness_delta, speaker_w, pitch_w, loudness_w = values
1316
+ score = (
1317
+ speaker_w * (1.0 - similarity)
1318
+ + pitch_w * pitch_delta
1319
+ + loudness_w * loudness_delta
1320
+ )
1321
+ return score if math.isfinite(score) and score >= 0.0 else math.inf
1322
+
1323
+
1324
+ @dataclass(frozen=True)
1325
+ class CandidateSequenceSelection:
1326
+ candidate_indices: tuple[int, ...]
1327
+ total_score: float
1328
+
1329
+
1330
+ def select_candidate_sequence(
1331
+ local_scores: Sequence[Sequence[float]],
1332
+ transition_scores: Sequence[Sequence[Sequence[float]]] = (),
1333
+ ) -> CandidateSequenceSelection | None:
1334
+ """Select the minimum-cost candidate path in ``O(N K^2)``.
1335
+
1336
+ Non-finite/negative scores remove only their candidate or edge. Malformed
1337
+ matrices and graphs with no complete finite path return ``None`` so callers
1338
+ cannot accidentally fall back to an unverified candidate.
1339
+ """
1340
+
1341
+ try:
1342
+ local_rows = [list(row) for row in local_scores]
1343
+ except TypeError:
1344
+ return None
1345
+ if not local_rows or any(not row for row in local_rows):
1346
+ return None
1347
+ safe_local = [
1348
+ [
1349
+ score if score is not None else math.inf
1350
+ for score in (_finite_number(value, minimum=0.0) for value in row)
1351
+ ]
1352
+ for row in local_rows
1353
+ ]
1354
+
1355
+ if len(safe_local) == 1:
1356
+ try:
1357
+ if len(transition_scores) != 0:
1358
+ return None
1359
+ except TypeError:
1360
+ return None
1361
+ best_index = min(range(len(safe_local[0])), key=safe_local[0].__getitem__)
1362
+ best_score = safe_local[0][best_index]
1363
+ if not math.isfinite(best_score):
1364
+ return None
1365
+ return CandidateSequenceSelection((best_index,), best_score)
1366
+
1367
+ try:
1368
+ transitions = [[list(row) for row in matrix] for matrix in transition_scores]
1369
+ except TypeError:
1370
+ return None
1371
+ if len(transitions) != len(safe_local) - 1:
1372
+ return None
1373
+ safe_transitions: list[list[list[float]]] = []
1374
+ for index, matrix in enumerate(transitions):
1375
+ previous_count = len(safe_local[index])
1376
+ current_count = len(safe_local[index + 1])
1377
+ if len(matrix) != previous_count or any(len(row) != current_count for row in matrix):
1378
+ return None
1379
+ safe_transitions.append(
1380
+ [
1381
+ [
1382
+ score if score is not None else math.inf
1383
+ for score in (_finite_number(value, minimum=0.0) for value in row)
1384
+ ]
1385
+ for row in matrix
1386
+ ]
1387
+ )
1388
+
1389
+ previous_costs = safe_local[0]
1390
+ backpointers: list[list[int]] = []
1391
+ for step in range(1, len(safe_local)):
1392
+ current_costs = [math.inf] * len(safe_local[step])
1393
+ current_backpointers = [-1] * len(safe_local[step])
1394
+ for current_index, local_score in enumerate(safe_local[step]):
1395
+ if not math.isfinite(local_score):
1396
+ continue
1397
+ for previous_index, previous_score in enumerate(previous_costs):
1398
+ edge_score = safe_transitions[step - 1][previous_index][current_index]
1399
+ if not math.isfinite(previous_score) or not math.isfinite(edge_score):
1400
+ continue
1401
+ total = previous_score + edge_score + local_score
1402
+ if math.isfinite(total) and total < current_costs[current_index]:
1403
+ current_costs[current_index] = total
1404
+ current_backpointers[current_index] = previous_index
1405
+ previous_costs = current_costs
1406
+ backpointers.append(current_backpointers)
1407
+
1408
+ final_index = min(range(len(previous_costs)), key=previous_costs.__getitem__)
1409
+ total_score = previous_costs[final_index]
1410
+ if not math.isfinite(total_score):
1411
+ return None
1412
+ indices = [final_index]
1413
+ for pointers in reversed(backpointers):
1414
+ final_index = pointers[final_index]
1415
+ if final_index < 0:
1416
+ return None
1417
+ indices.append(final_index)
1418
+ indices.reverse()
1419
+ return CandidateSequenceSelection(tuple(indices), total_score)
1420
+
1421
+
1422
  @torch.no_grad()
1423
  def extract_windowed_speaker_embedding(
1424
  wav_path: str,
quality_runtime.py ADDED
@@ -0,0 +1,1610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fail-closed quality runtime helpers for the BlueMagpie-TTS Space.
2
+
3
+ The module deliberately has no import-time model downloads. Both Whisper and
4
+ ECAPA are supplied through small injectable boundaries so the online Space can
5
+ load the real models lazily while unit tests remain deterministic and offline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import operator
12
+ import threading
13
+ from dataclasses import dataclass
14
+ from math import gcd
15
+ from typing import Any, Callable, Sequence
16
+
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn.functional as torch_functional
20
+
21
+ from production import AsrComparison, CandidateSequenceSelection, compare_asr_text
22
+
23
+
24
+ WHISPER_MODEL_ID = "openai/whisper-large-v3-turbo"
25
+ WHISPER_REVISION = "41f01f3fe87f28c78e2fbf8b568835947dd65ed9"
26
+ WHISPER_SAMPLE_RATE = 16_000
27
+ WHISPER_MAX_SEGMENT_SECONDS = 28.0
28
+ REQUEST_SEED_LIMIT = 2**31
29
+ ACTIVE_VOICE_TOP_DB = 35.0
30
+ ACTIVE_VOICE_FRAME_MS = 25.0
31
+ ACTIVE_VOICE_HOP_MS = 10.0
32
+ ACTIVE_VOICE_MIN_RMS = 1.0e-4
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class GenerationPolicy:
37
+ """Candidate-specific endpoint duration estimate used by the Space."""
38
+
39
+ name: str
40
+ cjk_cps: float
41
+ ascii_cps: float
42
+ hard_stop_margin_steps: int
43
+
44
+
45
+ BASE_GENERATION_POLICY = GenerationPolicy(
46
+ name="base",
47
+ cjk_cps=5.2,
48
+ ascii_cps=4.6,
49
+ hard_stop_margin_steps=1,
50
+ )
51
+ SAFE_DURATION_GENERATION_POLICY = GenerationPolicy(
52
+ name="safe_duration",
53
+ cjk_cps=4.6,
54
+ ascii_cps=4.0,
55
+ hard_stop_margin_steps=1,
56
+ )
57
+
58
+
59
+ def generation_policy_for_candidate_offset(candidate_offset: int) -> GenerationPolicy:
60
+ """Map candidate zero to base and every retry to the safe estimate.
61
+
62
+ The policy changes only the native-duration endpoint estimate. It is not a
63
+ minimum-length policy and therefore never holds the generation loop open to
64
+ enforce playback pace.
65
+ """
66
+
67
+ if isinstance(candidate_offset, (bool, np.bool_)):
68
+ raise ValueError("candidate_offset must be a non-negative integer")
69
+ try:
70
+ offset = operator.index(candidate_offset)
71
+ except (TypeError, ValueError, OverflowError) as error:
72
+ raise ValueError("candidate_offset must be a non-negative integer") from error
73
+ if offset < 0:
74
+ raise ValueError("candidate_offset must be a non-negative integer")
75
+ return BASE_GENERATION_POLICY if offset == 0 else SAFE_DURATION_GENERATION_POLICY
76
+
77
+
78
+ def resolve_request_seed(
79
+ request_seed: int | None,
80
+ random_seed_factory: Callable[[int], int],
81
+ ) -> int:
82
+ """Return a validated root seed, drawing randomness only for ``None``."""
83
+
84
+ candidate = (
85
+ random_seed_factory(REQUEST_SEED_LIMIT)
86
+ if request_seed is None
87
+ else request_seed
88
+ )
89
+ if isinstance(candidate, (bool, np.bool_)):
90
+ raise ValueError(f"request_seed must be an integer in [0, {REQUEST_SEED_LIMIT})")
91
+ try:
92
+ # ``operator.index`` semantics reject floats and numeric strings while
93
+ # accepting Python and NumPy integer scalars.
94
+ seed = operator.index(candidate)
95
+ except (AttributeError, TypeError, ValueError, OverflowError) as error:
96
+ raise ValueError(
97
+ f"request_seed must be an integer in [0, {REQUEST_SEED_LIMIT})"
98
+ ) from error
99
+ seed = int(seed)
100
+ if not 0 <= seed < REQUEST_SEED_LIMIT:
101
+ raise ValueError(f"request_seed must be an integer in [0, {REQUEST_SEED_LIMIT})")
102
+ return seed
103
+
104
+
105
+ def _finite_float(
106
+ value: Any,
107
+ *,
108
+ minimum: float | None = None,
109
+ maximum: float | None = None,
110
+ ) -> float | None:
111
+ if isinstance(value, (bool, np.bool_)):
112
+ return None
113
+ try:
114
+ result = float(value)
115
+ except (TypeError, ValueError, OverflowError):
116
+ return None
117
+ if not math.isfinite(result):
118
+ return None
119
+ if minimum is not None and result < minimum:
120
+ return None
121
+ if maximum is not None and result > maximum:
122
+ return None
123
+ return result
124
+
125
+
126
+ def _mono_audio(audio: np.ndarray | Sequence[float]) -> np.ndarray:
127
+ """Return contiguous mono float32 audio, rejecting ambiguous/bad inputs."""
128
+
129
+ waveform = np.asarray(audio)
130
+ if waveform.ndim == 1:
131
+ pass
132
+ elif waveform.ndim == 2:
133
+ first, second = waveform.shape
134
+ if first <= 8 and second > first:
135
+ waveform = waveform.mean(axis=0)
136
+ elif second <= 8 and first > second:
137
+ waveform = waveform.mean(axis=1)
138
+ else:
139
+ raise ValueError("2-D audio must have an identifiable channel axis (at most 8 channels)")
140
+ else:
141
+ raise ValueError("audio must be a one- or two-dimensional array")
142
+ waveform = np.asarray(waveform, dtype=np.float32).reshape(-1)
143
+ if waveform.size == 0:
144
+ raise ValueError("audio is empty")
145
+ if not np.isfinite(waveform).all():
146
+ raise ValueError("audio contains non-finite samples")
147
+ return np.ascontiguousarray(waveform)
148
+
149
+
150
+ def _resample_audio(audio: np.ndarray, sample_rate: int, target_rate: int) -> np.ndarray:
151
+ try:
152
+ source_rate = int(sample_rate)
153
+ destination_rate = int(target_rate)
154
+ except (TypeError, ValueError, OverflowError) as error:
155
+ raise ValueError("sample rates must be positive integers") from error
156
+ if source_rate <= 0 or destination_rate <= 0:
157
+ raise ValueError("sample rates must be positive integers")
158
+ if source_rate == destination_rate:
159
+ return np.ascontiguousarray(audio, dtype=np.float32)
160
+
161
+ # SpeechBrain already depends on SciPy. ``resample_poly`` avoids an
162
+ # undeclared optional ``librosa`` resampler dependency in the Space image.
163
+ from scipy.signal import resample_poly
164
+
165
+ common_divisor = gcd(source_rate, destination_rate)
166
+ output = resample_poly(
167
+ np.asarray(audio, dtype=np.float32),
168
+ destination_rate // common_divisor,
169
+ source_rate // common_divisor,
170
+ )
171
+ output = np.asarray(output, dtype=np.float32).reshape(-1)
172
+ if output.size == 0 or not np.isfinite(output).all():
173
+ raise ValueError("resampling produced invalid audio")
174
+ return np.ascontiguousarray(output)
175
+
176
+
177
+ def _trim_active_speech(
178
+ audio: np.ndarray,
179
+ *,
180
+ top_db: float = 35.0,
181
+ frame_length: int = 512,
182
+ hop_length: int = 128,
183
+ ) -> np.ndarray:
184
+ threshold_db = _finite_float(top_db, minimum=0.0)
185
+ if threshold_db is None:
186
+ raise ValueError("top_db must be finite and non-negative")
187
+ if float(np.max(np.abs(audio))) <= 1.0e-7:
188
+ raise ValueError("audio contains no active speech")
189
+
190
+ import librosa
191
+
192
+ intervals = librosa.effects.split(
193
+ audio,
194
+ top_db=threshold_db,
195
+ frame_length=max(32, int(frame_length)),
196
+ hop_length=max(1, int(hop_length)),
197
+ )
198
+ if intervals.size == 0:
199
+ raise ValueError("audio contains no active speech")
200
+ start = int(intervals[0, 0])
201
+ stop = int(intervals[-1, 1])
202
+ active = np.asarray(audio[start:stop], dtype=np.float32)
203
+ if active.size == 0 or float(np.max(np.abs(active))) <= 1.0e-7:
204
+ raise ValueError("audio contains no active speech")
205
+ return np.ascontiguousarray(active)
206
+
207
+
208
+ def active_voiced_intervals(
209
+ audio: np.ndarray | Sequence[float],
210
+ sample_rate: int,
211
+ *,
212
+ top_db: float = ACTIVE_VOICE_TOP_DB,
213
+ frame_ms: float = ACTIVE_VOICE_FRAME_MS,
214
+ hop_ms: float = ACTIVE_VOICE_HOP_MS,
215
+ min_rms: float = ACTIVE_VOICE_MIN_RMS,
216
+ ) -> tuple[tuple[int, int], ...]:
217
+ """Return the deterministic union of active RMS-frame intervals.
218
+
219
+ This is the same 25 ms / 10 ms, peak-minus-35 dB, 1e-4 floor contract
220
+ used by the independent hosted evaluator. Unlike first-to-last trimming,
221
+ the interval union excludes internal punctuation and joining pauses from
222
+ both online pace evidence and the speaker-gate duration threshold.
223
+ """
224
+
225
+ signal = _mono_audio(audio)
226
+ if isinstance(sample_rate, (bool, np.bool_)):
227
+ raise ValueError("sample rate must be positive")
228
+ try:
229
+ source_rate = operator.index(sample_rate)
230
+ except (TypeError, ValueError, OverflowError) as error:
231
+ raise ValueError("sample rate must be positive") from error
232
+ if source_rate <= 0:
233
+ raise ValueError("sample rate must be positive")
234
+ frame_duration = _finite_float(frame_ms, minimum=0.0)
235
+ hop_duration = _finite_float(hop_ms, minimum=0.0)
236
+ rms_floor = _finite_float(min_rms, minimum=0.0)
237
+ relative_db = _finite_float(top_db, minimum=0.0)
238
+ if (
239
+ frame_duration is None
240
+ or frame_duration <= 0.0
241
+ or hop_duration is None
242
+ or hop_duration <= 0.0
243
+ or rms_floor is None
244
+ or rms_floor <= 0.0
245
+ or relative_db is None
246
+ ):
247
+ raise ValueError("active-voice detector settings are invalid")
248
+
249
+ frame = max(1, int(round(frame_duration * source_rate / 1000.0)))
250
+ hop = max(1, int(round(hop_duration * source_rate / 1000.0)))
251
+ if signal.size <= frame:
252
+ starts = np.asarray([0], dtype=np.int64)
253
+ else:
254
+ starts = np.arange(0, signal.size - frame + 1, hop, dtype=np.int64)
255
+ final_start = signal.size - frame
256
+ if int(starts[-1]) != final_start:
257
+ starts = np.append(starts, final_start)
258
+ rms = np.asarray(
259
+ [
260
+ float(
261
+ np.sqrt(
262
+ np.mean(
263
+ np.square(
264
+ signal[int(start) : int(start) + frame],
265
+ dtype=np.float64,
266
+ )
267
+ )
268
+ )
269
+ )
270
+ for start in starts
271
+ ],
272
+ dtype=np.float64,
273
+ )
274
+ peak = float(rms.max(initial=0.0))
275
+ threshold = max(rms_floor, peak * 10.0 ** (-relative_db / 20.0))
276
+ active_starts = starts[rms >= threshold]
277
+ intervals: list[list[int]] = []
278
+ for raw_start in active_starts:
279
+ start = int(raw_start)
280
+ end = min(signal.size, start + frame)
281
+ if intervals and start <= intervals[-1][1]:
282
+ intervals[-1][1] = max(intervals[-1][1], end)
283
+ else:
284
+ intervals.append([start, end])
285
+ return tuple((start, end) for start, end in intervals)
286
+
287
+
288
+ def active_voiced_duration_seconds(
289
+ audio: np.ndarray | Sequence[float],
290
+ sample_rate: int,
291
+ **detector_kwargs: Any,
292
+ ) -> float:
293
+ """Measure active interval-union duration under the hosted gate contract."""
294
+
295
+ intervals = active_voiced_intervals(audio, sample_rate, **detector_kwargs)
296
+ active_samples = sum(end - start for start, end in intervals)
297
+ return active_samples / float(operator.index(sample_rate))
298
+
299
+
300
+ @dataclass(frozen=True)
301
+ class WhisperRuntime:
302
+ """Loaded processor/model pair for deterministic Whisper transcription."""
303
+
304
+ processor: Any
305
+ model: Any
306
+ device: torch.device
307
+ dtype: torch.dtype
308
+
309
+
310
+ def load_pinned_whisper_runtime(
311
+ *,
312
+ device: str | torch.device | None = None,
313
+ processor_factory: Any | None = None,
314
+ model_factory: Any | None = None,
315
+ ) -> WhisperRuntime:
316
+ """Load the exact ASR revision used by the quality gate.
317
+
318
+ Factory injection exists for offline tests. The default imports
319
+ ``transformers`` only when this function is first called.
320
+ """
321
+
322
+ if processor_factory is None or model_factory is None:
323
+ from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
324
+
325
+ processor_factory = processor_factory or AutoProcessor
326
+ model_factory = model_factory or AutoModelForSpeechSeq2Seq
327
+
328
+ selected_device = torch.device(
329
+ device if device is not None else ("cuda" if torch.cuda.is_available() else "cpu")
330
+ )
331
+ dtype = torch.float16 if selected_device.type == "cuda" else torch.float32
332
+ processor = processor_factory.from_pretrained(
333
+ WHISPER_MODEL_ID,
334
+ revision=WHISPER_REVISION,
335
+ )
336
+ model = model_factory.from_pretrained(
337
+ WHISPER_MODEL_ID,
338
+ revision=WHISPER_REVISION,
339
+ torch_dtype=dtype,
340
+ low_cpu_mem_usage=True,
341
+ use_safetensors=True,
342
+ )
343
+ model = model.to(selected_device)
344
+ model.eval()
345
+ return WhisperRuntime(
346
+ processor=processor,
347
+ model=model,
348
+ device=selected_device,
349
+ dtype=dtype,
350
+ )
351
+
352
+
353
+ class LazyWhisperASR:
354
+ """Thread-safe one-shot lazy loader with an injectable runtime factory."""
355
+
356
+ def __init__(self, runtime_loader: Callable[[], WhisperRuntime] | None = None) -> None:
357
+ self._runtime_loader = runtime_loader or load_pinned_whisper_runtime
358
+ self._runtime: WhisperRuntime | None = None
359
+ self._lock = threading.Lock()
360
+
361
+ def get_runtime(self) -> WhisperRuntime:
362
+ runtime = self._runtime
363
+ if runtime is not None:
364
+ return runtime
365
+ with self._lock:
366
+ if self._runtime is None:
367
+ self._runtime = self._runtime_loader()
368
+ return self._runtime
369
+
370
+
371
+ _DEFAULT_WHISPER = LazyWhisperASR()
372
+
373
+
374
+ def _split_whisper_audio(
375
+ waveform: np.ndarray,
376
+ *,
377
+ sample_rate: int = WHISPER_SAMPLE_RATE,
378
+ max_segment_seconds: float = WHISPER_MAX_SEGMENT_SECONDS,
379
+ boundary_search_seconds: float = 1.5,
380
+ ) -> tuple[np.ndarray, ...]:
381
+ """Split long audio near low-energy boundaries below Whisper's 30s cap."""
382
+
383
+ maximum_seconds = _finite_float(max_segment_seconds, minimum=1.0)
384
+ search_seconds = _finite_float(boundary_search_seconds, minimum=0.0)
385
+ if maximum_seconds is None or search_seconds is None or sample_rate <= 0:
386
+ raise ValueError("invalid Whisper segmentation settings")
387
+ maximum_samples = max(1, int(round(maximum_seconds * sample_rate)))
388
+ if waveform.size <= maximum_samples:
389
+ return (waveform,)
390
+
391
+ segment_count = int(math.ceil(waveform.size / maximum_samples))
392
+ ideal_samples = waveform.size / float(segment_count)
393
+ search_samples = int(round(search_seconds * sample_rate))
394
+ probe_radius = max(1, int(round(0.02 * sample_rate)))
395
+ probe_hop = max(1, int(round(0.02 * sample_rate)))
396
+ boundaries = [0]
397
+ for boundary_index in range(1, segment_count):
398
+ target = int(round(boundary_index * ideal_samples))
399
+ lower = max(boundaries[-1] + probe_radius, target - search_samples)
400
+ upper = min(waveform.size - probe_radius, target + search_samples)
401
+ if lower >= upper:
402
+ boundary = target
403
+ else:
404
+ probes = range(lower, upper + 1, probe_hop)
405
+ boundary = min(
406
+ probes,
407
+ key=lambda index: float(
408
+ np.mean(
409
+ np.square(
410
+ waveform[index - probe_radius : index + probe_radius],
411
+ dtype=np.float64,
412
+ )
413
+ )
414
+ ),
415
+ )
416
+ boundaries.append(boundary)
417
+ boundaries.append(waveform.size)
418
+ segments = tuple(
419
+ np.ascontiguousarray(waveform[start:stop], dtype=np.float32)
420
+ for start, stop in zip(boundaries, boundaries[1:])
421
+ )
422
+ if (
423
+ not segments
424
+ or any(segment.size == 0 for segment in segments)
425
+ or any(segment.size > int(round(30.0 * sample_rate)) for segment in segments)
426
+ ):
427
+ raise ValueError("failed to split audio within Whisper's segment limit")
428
+ return segments
429
+
430
+
431
+ @torch.inference_mode()
432
+ def transcribe_whisper(
433
+ audio: np.ndarray | Sequence[float],
434
+ sample_rate: int,
435
+ *,
436
+ lazy_asr: LazyWhisperASR | None = None,
437
+ runtime: WhisperRuntime | None = None,
438
+ language: str = "zh",
439
+ task: str = "transcribe",
440
+ max_new_tokens: int = 128,
441
+ ) -> str:
442
+ """Transcribe ndarray audio using deterministic decoding.
443
+
444
+ ``runtime`` and ``lazy_asr`` are mutually exclusive injection points. An
445
+ empty decoded string is returned as-is; the semantic verifier will reject
446
+ it rather than accepting an arbitrary TTS fallback.
447
+ """
448
+
449
+ if runtime is not None and lazy_asr is not None:
450
+ raise ValueError("pass either runtime or lazy_asr, not both")
451
+ token_limit = int(max_new_tokens)
452
+ if token_limit <= 0:
453
+ raise ValueError("max_new_tokens must be positive")
454
+ waveform = _resample_audio(_mono_audio(audio), int(sample_rate), WHISPER_SAMPLE_RATE)
455
+ segments = _split_whisper_audio(waveform)
456
+ selected_runtime = runtime or (lazy_asr or _DEFAULT_WHISPER).get_runtime()
457
+ processor_input: np.ndarray | list[np.ndarray]
458
+ processor_input = segments[0] if len(segments) == 1 else list(segments)
459
+ features = selected_runtime.processor(
460
+ processor_input,
461
+ sampling_rate=WHISPER_SAMPLE_RATE,
462
+ return_tensors="pt",
463
+ ).input_features
464
+ features = features.to(device=selected_runtime.device, dtype=selected_runtime.dtype)
465
+ token_ids = selected_runtime.model.generate(
466
+ features,
467
+ language=language,
468
+ task=task,
469
+ do_sample=False,
470
+ num_beams=1,
471
+ max_new_tokens=token_limit,
472
+ )
473
+ decoded = selected_runtime.processor.batch_decode(token_ids, skip_special_tokens=True)
474
+ if not decoded or len(decoded) != len(segments):
475
+ return ""
476
+ return " ".join(str(text).strip() for text in decoded if str(text).strip())
477
+
478
+
479
+ @dataclass(frozen=True)
480
+ class PreparedCandidateAudio:
481
+ """Validated candidate waveform and its ASR transcript."""
482
+
483
+ waveform: np.ndarray
484
+ duration_seconds: float
485
+ transcript_text: str
486
+
487
+
488
+ def prepare_candidate_audio(
489
+ audio: np.ndarray | Sequence[float],
490
+ sample_rate: int,
491
+ *,
492
+ transcriber: Callable[[np.ndarray, int], str] | None = None,
493
+ ) -> PreparedCandidateAudio | None:
494
+ """Prepare one candidate, returning ``None`` for candidate-data errors.
495
+
496
+ Invalid/empty/non-finite audio and a transcriber's ``ValueError`` describe
497
+ an unusable candidate, not a service outage. They therefore become a
498
+ normal gate rejection so the cascade can try the next seed. Runtime and
499
+ I/O failures deliberately propagate and abort the request fail-closed.
500
+ """
501
+
502
+ try:
503
+ selected_sample_rate = int(sample_rate)
504
+ if selected_sample_rate <= 0:
505
+ raise ValueError("sample_rate must be positive")
506
+ waveform = _mono_audio(audio)
507
+ transcript = (transcriber or transcribe_whisper)(
508
+ waveform,
509
+ selected_sample_rate,
510
+ )
511
+ if not isinstance(transcript, str):
512
+ raise ValueError("ASR transcript must be a string")
513
+ except (TypeError, ValueError, OverflowError):
514
+ return None
515
+ return PreparedCandidateAudio(
516
+ waveform=waveform,
517
+ duration_seconds=waveform.size / float(selected_sample_rate),
518
+ transcript_text=transcript.strip(),
519
+ )
520
+
521
+
522
+ @torch.inference_mode()
523
+ def _encode_speaker_segments(
524
+ segments: Sequence[np.ndarray],
525
+ encoder: Any,
526
+ *,
527
+ device: str | torch.device,
528
+ ) -> np.ndarray:
529
+ """Encode a variable-length segment batch in one ECAPA forward pass."""
530
+
531
+ waveforms = tuple(np.asarray(segment, dtype=np.float32).reshape(-1) for segment in segments)
532
+ if not waveforms or any(
533
+ waveform.size == 0 or not np.isfinite(waveform).all()
534
+ for waveform in waveforms
535
+ ):
536
+ raise ValueError("speaker segments must be non-empty and finite")
537
+ maximum_length = max(waveform.size for waveform in waveforms)
538
+ batch = np.zeros((len(waveforms), maximum_length), dtype=np.float32)
539
+ relative_lengths = np.empty(len(waveforms), dtype=np.float32)
540
+ for index, waveform in enumerate(waveforms):
541
+ batch[index, : waveform.size] = waveform
542
+ relative_lengths[index] = waveform.size / float(maximum_length)
543
+
544
+ selected_device = torch.device(device)
545
+ tensor = torch.from_numpy(batch).to(selected_device)
546
+ wav_lens = torch.from_numpy(relative_lengths).to(selected_device)
547
+ embeddings = encoder.encode_batch(tensor, wav_lens=wav_lens)
548
+ embeddings = torch.as_tensor(embeddings).detach().float()
549
+ if embeddings.ndim == 0 or embeddings.shape[0] != len(waveforms):
550
+ raise ValueError("speaker encoder returned an invalid batch size")
551
+ embeddings = embeddings.reshape(len(waveforms), -1)
552
+ if embeddings.shape[1] == 0 or not torch.isfinite(embeddings).all():
553
+ raise ValueError("speaker encoder returned an invalid embedding")
554
+ norms = torch.linalg.vector_norm(embeddings, dim=1)
555
+ if not torch.isfinite(norms).all() or bool(torch.any(norms <= 1.0e-8)):
556
+ raise ValueError("speaker encoder returned a zero-norm embedding")
557
+ normalized = torch_functional.normalize(embeddings, dim=1).cpu().numpy().astype(np.float32)
558
+ if not np.isfinite(normalized).all():
559
+ raise ValueError("speaker encoder returned a non-finite embedding")
560
+ return normalized
561
+
562
+
563
+ @torch.inference_mode()
564
+ def speaker_embedding_from_audio(
565
+ audio: np.ndarray | Sequence[float],
566
+ sample_rate: int,
567
+ encoder: Any,
568
+ *,
569
+ device: str | torch.device = "cpu",
570
+ target_sample_rate: int = 16_000,
571
+ active_top_db: float = 35.0,
572
+ ) -> np.ndarray:
573
+ """Extract one normalized ECAPA embedding from active ndarray speech."""
574
+
575
+ waveform = _mono_audio(audio)
576
+ waveform = _resample_audio(waveform, int(sample_rate), int(target_sample_rate))
577
+ waveform = _trim_active_speech(waveform, top_db=active_top_db)
578
+ return _encode_speaker_segments((waveform,), encoder, device=device)[0]
579
+
580
+
581
+ def cosine_similarity(left: np.ndarray | Sequence[float], right: np.ndarray | Sequence[float]) -> float:
582
+ """Return a finite cosine similarity, raising on unusable embeddings."""
583
+
584
+ left_array = np.asarray(left, dtype=np.float64).reshape(-1)
585
+ right_array = np.asarray(right, dtype=np.float64).reshape(-1)
586
+ if left_array.size == 0 or left_array.shape != right_array.shape:
587
+ raise ValueError("speaker embeddings must have equal non-empty shapes")
588
+ if not np.isfinite(left_array).all() or not np.isfinite(right_array).all():
589
+ raise ValueError("speaker embeddings must be finite")
590
+ denominator = float(np.linalg.norm(left_array) * np.linalg.norm(right_array))
591
+ if not math.isfinite(denominator) or denominator <= 1.0e-12:
592
+ raise ValueError("speaker embeddings must have non-zero norm")
593
+ similarity = float(np.dot(left_array, right_array) / denominator)
594
+ if not math.isfinite(similarity):
595
+ raise ValueError("speaker cosine similarity is non-finite")
596
+ return float(np.clip(similarity, -1.0, 1.0))
597
+
598
+
599
+ @dataclass(frozen=True)
600
+ class SpeakerEvidence:
601
+ similarity: float
602
+ begin_similarity: float
603
+ end_similarity: float
604
+ boundary_drop: float
605
+ active_duration_seconds: float
606
+ speaker_embedding: np.ndarray
607
+ active_rms_db: float
608
+
609
+
610
+ def speaker_evidence_from_audio(
611
+ audio: np.ndarray | Sequence[float],
612
+ sample_rate: int,
613
+ encoder: Any,
614
+ anchor_embedding: np.ndarray | Sequence[float],
615
+ *,
616
+ device: str | torch.device = "cpu",
617
+ edge_seconds: float = 1.5,
618
+ whole_window_seconds: float = 3.0,
619
+ whole_max_windows: int = 4,
620
+ active_top_db: float = 35.0,
621
+ ) -> SpeakerEvidence:
622
+ """Measure whole/begin/end anchor similarity on trimmed active speech."""
623
+
624
+ edge_duration = _finite_float(edge_seconds, minimum=0.01)
625
+ window_duration = _finite_float(whole_window_seconds, minimum=0.01)
626
+ try:
627
+ max_windows = int(whole_max_windows)
628
+ except (TypeError, ValueError, OverflowError):
629
+ max_windows = 0
630
+ if edge_duration is None or window_duration is None or max_windows <= 0:
631
+ raise ValueError("speaker window settings must be finite and positive")
632
+ waveform = _resample_audio(_mono_audio(audio), int(sample_rate), 16_000)
633
+ active_duration_seconds = active_voiced_duration_seconds(
634
+ waveform,
635
+ 16_000,
636
+ top_db=active_top_db,
637
+ )
638
+ active = _trim_active_speech(waveform, top_db=active_top_db)
639
+ edge_samples = max(1, int(round(edge_duration * 16_000)))
640
+ whole_window_samples = max(1, int(round(window_duration * 16_000)))
641
+ begin = active[:edge_samples]
642
+ end = active[-edge_samples:]
643
+ if active.size <= whole_window_samples:
644
+ whole_segments = [active]
645
+ else:
646
+ starts = np.linspace(
647
+ 0,
648
+ active.size - whole_window_samples,
649
+ num=max_windows,
650
+ ).round().astype(int)
651
+ whole_segments = [
652
+ active[start : start + whole_window_samples]
653
+ for start in dict.fromkeys(starts.tolist())
654
+ ]
655
+ embeddings = _encode_speaker_segments(
656
+ (*whole_segments, begin, end),
657
+ encoder,
658
+ device=device,
659
+ )
660
+ whole_embeddings = embeddings[: len(whole_segments)]
661
+ whole_embedding = np.mean(whole_embeddings, axis=0, dtype=np.float64)
662
+ whole_norm = float(np.linalg.norm(whole_embedding))
663
+ if not math.isfinite(whole_norm) or whole_norm <= 1.0e-8:
664
+ raise ValueError("speaker windows produced a zero-norm embedding")
665
+ whole_embedding = np.asarray(whole_embedding / whole_norm, dtype=np.float32)
666
+ begin_embedding, end_embedding = embeddings[-2:]
667
+ similarity = cosine_similarity(whole_embedding, anchor_embedding)
668
+ begin_similarity = cosine_similarity(begin_embedding, anchor_embedding)
669
+ end_similarity = cosine_similarity(end_embedding, anchor_embedding)
670
+ boundary_drop = max(0.0, begin_similarity - end_similarity)
671
+ active_rms = float(np.sqrt(np.mean(np.square(active, dtype=np.float64))))
672
+ if not math.isfinite(active_rms) or active_rms <= 1.0e-8:
673
+ raise ValueError("active speech has invalid RMS")
674
+ return SpeakerEvidence(
675
+ similarity=similarity,
676
+ begin_similarity=begin_similarity,
677
+ end_similarity=end_similarity,
678
+ boundary_drop=boundary_drop,
679
+ active_duration_seconds=active_duration_seconds,
680
+ speaker_embedding=whole_embedding.copy(),
681
+ active_rms_db=20.0 * math.log10(active_rms),
682
+ )
683
+
684
+
685
+ def active_audio_rms_db(audio: np.ndarray | Sequence[float], *, top_db: float = 35.0) -> float:
686
+ """Measure finite RMS dB on the active region of candidate audio."""
687
+
688
+ active = _trim_active_speech(_mono_audio(audio), top_db=top_db)
689
+ rms = float(np.sqrt(np.mean(np.square(active, dtype=np.float64))))
690
+ if not math.isfinite(rms) or rms <= 1.0e-8:
691
+ raise ValueError("active speech has invalid RMS")
692
+ return 20.0 * math.log10(rms)
693
+
694
+
695
+ @dataclass(frozen=True)
696
+ class CandidateObservation:
697
+ target_text: str
698
+ transcript_text: str
699
+ audio_duration_seconds: float
700
+ speaker_similarity: float | None = None
701
+ begin_speaker_similarity: float | None = None
702
+ end_speaker_similarity: float | None = None
703
+ pace_cps: float | None = None
704
+ truncated: bool = False
705
+
706
+
707
+ @dataclass(frozen=True)
708
+ class CandidateGateResult:
709
+ passed: bool
710
+ comparison: AsrComparison
711
+ speaker_gate_applied: bool
712
+ speaker_similarity: float | None
713
+ boundary_speaker_drop: float | None
714
+ pace_cps: float | None
715
+ score: float
716
+ rejection_reasons: tuple[str, ...]
717
+
718
+
719
+ @dataclass(frozen=True)
720
+ class ChunkCandidateArtifact:
721
+ """Acoustic evidence retained for sequence-level candidate selection."""
722
+
723
+ speaker_embedding: np.ndarray | None = None
724
+ rms_db: float | None = None
725
+ median_f0_hz: float | None = None
726
+
727
+
728
+ def verify_candidate(
729
+ observation: CandidateObservation,
730
+ *,
731
+ locale: str = "zh-TW",
732
+ short_text_units: int = 6,
733
+ short_text_max_cer: float = 0.0,
734
+ max_cer: float = 0.20,
735
+ prefix_units: int = 6,
736
+ suffix_units: int = 6,
737
+ max_prefix_cer: float = 0.0,
738
+ max_suffix_cer: float = 0.0,
739
+ max_extra_tail_units: int = 0,
740
+ short_audio_seconds: float = 1.5,
741
+ min_speaker_similarity: float = 0.10,
742
+ max_boundary_speaker_drop: float = 0.03,
743
+ max_pace_cps: float | None = None,
744
+ speaker_weight: float = 0.05,
745
+ boundary_weight: float = 0.10,
746
+ ) -> CandidateGateResult:
747
+ """Apply strict semantic and duration-aware speaker gates to a candidate."""
748
+
749
+ duration = _finite_float(observation.audio_duration_seconds, minimum=0.0)
750
+ short_duration_limit = _finite_float(short_audio_seconds, minimum=0.0)
751
+ general_cer_limit = _finite_float(max_cer, minimum=0.0)
752
+ exact_cer_limit = _finite_float(short_text_max_cer, minimum=0.0)
753
+ min_similarity = _finite_float(min_speaker_similarity, minimum=-1.0, maximum=1.0)
754
+ max_boundary = _finite_float(max_boundary_speaker_drop, minimum=0.0)
755
+ max_pace = None if max_pace_cps is None else _finite_float(max_pace_cps, minimum=0.0)
756
+ speaker_cost_weight = _finite_float(speaker_weight, minimum=0.0)
757
+ boundary_cost_weight = _finite_float(boundary_weight, minimum=0.0)
758
+ try:
759
+ short_unit_limit = max(0, int(short_text_units))
760
+ except (TypeError, ValueError, OverflowError):
761
+ short_unit_limit = -1
762
+
763
+ # First normalize with a permissive finite limit to determine target units.
764
+ preliminary = compare_asr_text(
765
+ observation.target_text,
766
+ observation.transcript_text,
767
+ locale=locale,
768
+ prefix_units=prefix_units,
769
+ suffix_units=suffix_units,
770
+ max_cer=general_cer_limit if general_cer_limit is not None else math.nan,
771
+ max_prefix_cer=max_prefix_cer,
772
+ max_suffix_cer=max_suffix_cer,
773
+ max_extra_tail_units=max_extra_tail_units,
774
+ )
775
+ selected_cer_limit = general_cer_limit
776
+ if short_unit_limit >= 0 and len(preliminary.target_text) <= short_unit_limit:
777
+ selected_cer_limit = exact_cer_limit
778
+ comparison = compare_asr_text(
779
+ observation.target_text,
780
+ observation.transcript_text,
781
+ locale=locale,
782
+ prefix_units=prefix_units,
783
+ suffix_units=suffix_units,
784
+ max_cer=selected_cer_limit if selected_cer_limit is not None else math.nan,
785
+ max_prefix_cer=max_prefix_cer,
786
+ max_suffix_cer=max_suffix_cer,
787
+ max_extra_tail_units=max_extra_tail_units,
788
+ )
789
+
790
+ reasons: list[str] = []
791
+ if duration is None or duration <= 0.0:
792
+ reasons.append("invalid_audio_duration")
793
+ if observation.truncated is not False:
794
+ reasons.append("truncated")
795
+ if not comparison.passed:
796
+ reasons.append("semantic_gate")
797
+ pace = None
798
+ if max_pace_cps is not None:
799
+ pace = _finite_float(observation.pace_cps, minimum=0.0)
800
+ if max_pace is None:
801
+ reasons.append("invalid_gate_config")
802
+ elif pace is None:
803
+ reasons.append("missing_pace_evidence")
804
+ elif pace > max_pace:
805
+ reasons.append("pace_too_fast")
806
+
807
+ valid_common_config = all(
808
+ value is not None
809
+ for value in (
810
+ short_duration_limit,
811
+ general_cer_limit,
812
+ exact_cer_limit,
813
+ min_similarity,
814
+ max_boundary,
815
+ speaker_cost_weight,
816
+ boundary_cost_weight,
817
+ )
818
+ ) and short_unit_limit >= 0
819
+ if not valid_common_config:
820
+ reasons.append("invalid_gate_config")
821
+
822
+ speaker_gate_applied = bool(
823
+ duration is not None
824
+ and short_duration_limit is not None
825
+ and duration >= short_duration_limit
826
+ )
827
+ similarity: float | None = None
828
+ boundary_drop: float | None = None
829
+ if speaker_gate_applied:
830
+ similarity = _finite_float(
831
+ observation.speaker_similarity,
832
+ minimum=-1.0,
833
+ maximum=1.0,
834
+ )
835
+ begin_similarity = _finite_float(
836
+ observation.begin_speaker_similarity,
837
+ minimum=-1.0,
838
+ maximum=1.0,
839
+ )
840
+ end_similarity = _finite_float(
841
+ observation.end_speaker_similarity,
842
+ minimum=-1.0,
843
+ maximum=1.0,
844
+ )
845
+ if similarity is None or begin_similarity is None or end_similarity is None:
846
+ reasons.append("missing_speaker_evidence")
847
+ else:
848
+ boundary_drop = max(0.0, begin_similarity - end_similarity)
849
+ if min_similarity is None or similarity < min_similarity:
850
+ reasons.append("speaker_similarity")
851
+ if max_boundary is None or boundary_drop > max_boundary:
852
+ reasons.append("boundary_speaker_drop")
853
+
854
+ score = math.inf
855
+ if not reasons:
856
+ score = comparison.cer
857
+ if speaker_gate_applied:
858
+ assert similarity is not None and boundary_drop is not None
859
+ assert speaker_cost_weight is not None and boundary_cost_weight is not None
860
+ score += speaker_cost_weight * (1.0 - similarity)
861
+ score += boundary_cost_weight * boundary_drop
862
+ if not math.isfinite(score) or score < 0.0:
863
+ reasons.append("nonfinite_score")
864
+ score = math.inf
865
+
866
+ return CandidateGateResult(
867
+ passed=not reasons,
868
+ comparison=comparison,
869
+ speaker_gate_applied=speaker_gate_applied,
870
+ speaker_similarity=similarity,
871
+ boundary_speaker_drop=boundary_drop,
872
+ pace_cps=pace,
873
+ score=score,
874
+ rejection_reasons=tuple(reasons),
875
+ )
876
+
877
+
878
+ @dataclass(frozen=True)
879
+ class TrajectoryGateResult:
880
+ passed: bool
881
+ candidate_results: tuple[CandidateGateResult, ...]
882
+ score: float
883
+ rejection_reasons: tuple[str, ...]
884
+ chunk_artifacts: tuple[ChunkCandidateArtifact, ...] = ()
885
+
886
+
887
+ def verify_trajectory(
888
+ observations: Sequence[CandidateObservation],
889
+ *,
890
+ chunk_artifacts: Sequence[ChunkCandidateArtifact] = (),
891
+ **candidate_gate_kwargs: Any,
892
+ ) -> TrajectoryGateResult:
893
+ """Require every chunk in a non-empty trajectory to pass all hard gates."""
894
+
895
+ try:
896
+ candidates = tuple(observations)
897
+ except TypeError:
898
+ candidates = ()
899
+ if not candidates:
900
+ return TrajectoryGateResult(False, (), math.inf, ("empty_trajectory",))
901
+
902
+ try:
903
+ artifacts = tuple(chunk_artifacts)
904
+ except TypeError:
905
+ artifacts = ()
906
+ if artifacts and (
907
+ len(artifacts) != len(candidates)
908
+ or any(not isinstance(artifact, ChunkCandidateArtifact) for artifact in artifacts)
909
+ ):
910
+ return TrajectoryGateResult(
911
+ False,
912
+ (),
913
+ math.inf,
914
+ ("invalid_chunk_artifacts",),
915
+ )
916
+
917
+ results: list[CandidateGateResult] = []
918
+ rejection_reasons: list[str] = []
919
+ for index, observation in enumerate(candidates):
920
+ try:
921
+ result = verify_candidate(observation, **candidate_gate_kwargs)
922
+ except (TypeError, ValueError, OverflowError):
923
+ # A malformed observation must reject the entire trajectory.
924
+ comparison = compare_asr_text("", "")
925
+ result = CandidateGateResult(
926
+ passed=False,
927
+ comparison=comparison,
928
+ speaker_gate_applied=False,
929
+ speaker_similarity=None,
930
+ boundary_speaker_drop=None,
931
+ pace_cps=None,
932
+ score=math.inf,
933
+ rejection_reasons=("malformed_observation",),
934
+ )
935
+ results.append(result)
936
+ rejection_reasons.extend(f"chunk_{index}:{reason}" for reason in result.rejection_reasons)
937
+ passed = bool(results) and all(result.passed for result in results)
938
+ score = sum(result.score for result in results) if passed else math.inf
939
+ if not math.isfinite(score):
940
+ passed = False
941
+ score = math.inf
942
+ if not rejection_reasons:
943
+ rejection_reasons.append("nonfinite_trajectory_score")
944
+ return TrajectoryGateResult(
945
+ passed=passed,
946
+ candidate_results=tuple(results),
947
+ score=score,
948
+ rejection_reasons=tuple(rejection_reasons),
949
+ chunk_artifacts=artifacts,
950
+ )
951
+
952
+
953
+ def qualify_trajectory_with_joined_output(
954
+ local_verification: TrajectoryGateResult,
955
+ joined_verification: TrajectoryGateResult,
956
+ ) -> TrajectoryGateResult:
957
+ """Require joined-output safety without discarding DP-local evidence.
958
+
959
+ ``candidate_results`` and ``chunk_artifacts`` always stay local to the
960
+ generated chunks. This lets sequence DP reuse individually safe chunks
961
+ when RMS matching, fades, pauses or crossfade make the same-seed joined
962
+ waveform fail its whole-output gate.
963
+ """
964
+
965
+ if not isinstance(local_verification, TrajectoryGateResult):
966
+ raise TypeError("local_verification must be a TrajectoryGateResult")
967
+ if local_verification.passed is not True or not math.isfinite(
968
+ local_verification.score
969
+ ):
970
+ return local_verification
971
+ if not isinstance(joined_verification, TrajectoryGateResult):
972
+ raise TypeError("joined_verification must be a TrajectoryGateResult")
973
+
974
+ joined_passed = bool(
975
+ joined_verification.passed is True
976
+ and math.isfinite(joined_verification.score)
977
+ and len(joined_verification.candidate_results) == 1
978
+ )
979
+ if joined_passed:
980
+ return local_verification
981
+
982
+ joined_reasons = joined_verification.rejection_reasons or (
983
+ "invalid_joined_verification",
984
+ )
985
+ return TrajectoryGateResult(
986
+ passed=False,
987
+ candidate_results=local_verification.candidate_results,
988
+ score=math.inf,
989
+ rejection_reasons=tuple(
990
+ f"joined_output:{reason}" for reason in joined_reasons
991
+ ),
992
+ chunk_artifacts=local_verification.chunk_artifacts,
993
+ )
994
+
995
+
996
+ class NoQualifiedCandidateError(RuntimeError):
997
+ """Raised when the full adaptive cascade has no verified trajectory."""
998
+
999
+
1000
+ class FinalOutputRejectedError(RuntimeError):
1001
+ """Raised when post-join output fails the final whole-waveform gate."""
1002
+
1003
+
1004
+ def require_verified_final_output(
1005
+ verification: TrajectoryGateResult,
1006
+ ) -> TrajectoryGateResult:
1007
+ """Return verified final evidence or reject without an audio fallback."""
1008
+
1009
+ if not isinstance(verification, TrajectoryGateResult):
1010
+ raise FinalOutputRejectedError("final verifier returned an invalid result")
1011
+ if (
1012
+ verification.passed is not True
1013
+ or not verification.candidate_results
1014
+ or not all(result.passed for result in verification.candidate_results)
1015
+ or not math.isfinite(verification.score)
1016
+ ):
1017
+ reasons = ",".join(verification.rejection_reasons) or "unsafe_final_output"
1018
+ raise FinalOutputRejectedError(f"final output rejected: {reasons}")
1019
+ return verification
1020
+
1021
+
1022
+ @dataclass(frozen=True)
1023
+ class CascadeResult:
1024
+ trajectory: Any
1025
+ verification: TrajectoryGateResult
1026
+ seed: int | None
1027
+ candidate_index: int | None
1028
+ attempted_seeds: tuple[int, ...]
1029
+ chunk_candidate_indices: tuple[int, ...] = ()
1030
+ chunk_seeds: tuple[int, ...] = ()
1031
+ selection_mode: str = "whole_trajectory"
1032
+ sequence_path_rank: int | None = None
1033
+ sequence_paths_checked: int = 0
1034
+
1035
+
1036
+ def select_k_candidate_sequences(
1037
+ local_scores: Sequence[Sequence[float]],
1038
+ transition_scores: Sequence[Sequence[Sequence[float]]] = (),
1039
+ *,
1040
+ max_paths: int = 3,
1041
+ ) -> tuple[CandidateSequenceSelection, ...]:
1042
+ """Return up to three distinct finite paths in stable cost order.
1043
+
1044
+ Each DP state retains only its ``max_paths`` best prefixes. This keeps the
1045
+ search bounded at ``O(N K² max_paths)`` while still producing exact k-best
1046
+ paths for the requested small bound. Single-chunk requests intentionally
1047
+ return no sequence fallback.
1048
+ """
1049
+
1050
+ if isinstance(max_paths, (bool, np.bool_)):
1051
+ raise ValueError("max_paths must be an integer between 1 and 3")
1052
+ try:
1053
+ path_limit = operator.index(max_paths)
1054
+ except (TypeError, ValueError, OverflowError) as error:
1055
+ raise ValueError("max_paths must be an integer between 1 and 3") from error
1056
+ if not 1 <= path_limit <= 3:
1057
+ raise ValueError("max_paths must be an integer between 1 and 3")
1058
+
1059
+ try:
1060
+ raw_local = [list(row) for row in local_scores]
1061
+ except TypeError:
1062
+ return ()
1063
+ if len(raw_local) <= 1 or any(not row for row in raw_local):
1064
+ return ()
1065
+ safe_local = [
1066
+ [
1067
+ score if score is not None else math.inf
1068
+ for score in (_finite_float(value, minimum=0.0) for value in row)
1069
+ ]
1070
+ for row in raw_local
1071
+ ]
1072
+ try:
1073
+ raw_transitions = [
1074
+ [list(row) for row in matrix]
1075
+ for matrix in transition_scores
1076
+ ]
1077
+ except TypeError:
1078
+ return ()
1079
+ if len(raw_transitions) != len(safe_local) - 1:
1080
+ return ()
1081
+
1082
+ safe_transitions: list[list[list[float]]] = []
1083
+ for step, matrix in enumerate(raw_transitions):
1084
+ previous_count = len(safe_local[step])
1085
+ current_count = len(safe_local[step + 1])
1086
+ if len(matrix) != previous_count or any(
1087
+ len(row) != current_count for row in matrix
1088
+ ):
1089
+ return ()
1090
+ safe_transitions.append(
1091
+ [
1092
+ [
1093
+ score if score is not None else math.inf
1094
+ for score in (
1095
+ _finite_float(value, minimum=0.0)
1096
+ for value in row
1097
+ )
1098
+ ]
1099
+ for row in matrix
1100
+ ]
1101
+ )
1102
+
1103
+ # One list of (cost, path) prefixes for each current candidate position.
1104
+ states: list[list[tuple[float, tuple[int, ...]]]] = []
1105
+ for candidate_index, score in enumerate(safe_local[0]):
1106
+ states.append(
1107
+ [(score, (candidate_index,))] if math.isfinite(score) else []
1108
+ )
1109
+
1110
+ for step in range(1, len(safe_local)):
1111
+ next_states: list[list[tuple[float, tuple[int, ...]]]] = []
1112
+ for current_index, local_score in enumerate(safe_local[step]):
1113
+ options: dict[tuple[int, ...], float] = {}
1114
+ if math.isfinite(local_score):
1115
+ for previous_index, prefixes in enumerate(states):
1116
+ edge_score = safe_transitions[step - 1][previous_index][
1117
+ current_index
1118
+ ]
1119
+ if not math.isfinite(edge_score):
1120
+ continue
1121
+ for previous_score, prefix in prefixes:
1122
+ total = previous_score + edge_score + local_score
1123
+ path = prefix + (current_index,)
1124
+ if math.isfinite(total):
1125
+ old_score = options.get(path, math.inf)
1126
+ if total < old_score:
1127
+ options[path] = total
1128
+ ranked = sorted(
1129
+ ((score, path) for path, score in options.items()),
1130
+ key=lambda item: (item[0], item[1]),
1131
+ )[:path_limit]
1132
+ next_states.append(ranked)
1133
+ states = next_states
1134
+
1135
+ complete: dict[tuple[int, ...], float] = {}
1136
+ for prefixes in states:
1137
+ for score, path in prefixes:
1138
+ old_score = complete.get(path, math.inf)
1139
+ if score < old_score:
1140
+ complete[path] = score
1141
+ ranked_complete = sorted(
1142
+ ((score, path) for path, score in complete.items()),
1143
+ key=lambda item: (item[0], item[1]),
1144
+ )[:path_limit]
1145
+ return tuple(
1146
+ CandidateSequenceSelection(candidate_indices=path, total_score=score)
1147
+ for score, path in ranked_complete
1148
+ )
1149
+
1150
+
1151
+ def candidate_chunk_transition_score(
1152
+ previous_result: CandidateGateResult,
1153
+ previous_artifact: ChunkCandidateArtifact,
1154
+ current_result: CandidateGateResult,
1155
+ current_artifact: ChunkCandidateArtifact,
1156
+ *,
1157
+ speaker_weight: float = 1.0,
1158
+ rms_db_weight: float = 0.05,
1159
+ median_f0_weight: float = 0.10,
1160
+ ) -> float:
1161
+ """Return a finite adjacent-chunk cost or ``inf`` for an unsafe edge."""
1162
+
1163
+ if previous_result.passed is not True or current_result.passed is not True:
1164
+ return math.inf
1165
+ speaker_w = _finite_float(speaker_weight, minimum=0.0)
1166
+ rms_w = _finite_float(rms_db_weight, minimum=0.0)
1167
+ f0_w = _finite_float(median_f0_weight, minimum=0.0)
1168
+ previous_rms = _finite_float(previous_artifact.rms_db)
1169
+ current_rms = _finite_float(current_artifact.rms_db)
1170
+ if None in (speaker_w, rms_w, f0_w, previous_rms, current_rms):
1171
+ return math.inf
1172
+
1173
+ previous_embedding = previous_artifact.speaker_embedding
1174
+ current_embedding = current_artifact.speaker_embedding
1175
+ speaker_cost = 0.0
1176
+ if previous_result.speaker_gate_applied and previous_embedding is None:
1177
+ return math.inf
1178
+ if current_result.speaker_gate_applied and current_embedding is None:
1179
+ return math.inf
1180
+ if previous_embedding is not None and current_embedding is not None:
1181
+ try:
1182
+ speaker_cost = 1.0 - cosine_similarity(previous_embedding, current_embedding)
1183
+ except ValueError:
1184
+ return math.inf
1185
+
1186
+ assert previous_rms is not None and current_rms is not None
1187
+ rms_cost = abs(previous_rms - current_rms)
1188
+ f0_cost = 0.0
1189
+ previous_f0 = previous_artifact.median_f0_hz
1190
+ current_f0 = current_artifact.median_f0_hz
1191
+ if previous_f0 is not None or current_f0 is not None:
1192
+ previous_pitch = _finite_float(previous_f0, minimum=1.0)
1193
+ current_pitch = _finite_float(current_f0, minimum=1.0)
1194
+ if previous_pitch is None or current_pitch is None:
1195
+ return math.inf
1196
+ f0_cost = abs(math.log2(current_pitch / previous_pitch))
1197
+
1198
+ assert speaker_w is not None and rms_w is not None and f0_w is not None
1199
+ score = speaker_w * speaker_cost + rms_w * rms_cost + f0_w * f0_cost
1200
+ return score if math.isfinite(score) and score >= 0.0 else math.inf
1201
+
1202
+
1203
+ def candidate_limit_for_chunk_budget(
1204
+ chunk_count: int,
1205
+ *,
1206
+ max_candidates: int = 10,
1207
+ max_generated_chunks: int = 20,
1208
+ ) -> int:
1209
+ """Return a candidate cap that never exceeds the generated-chunk budget."""
1210
+
1211
+ if isinstance(chunk_count, (bool, np.bool_)):
1212
+ raise ValueError("chunk_count must be a positive integer")
1213
+ try:
1214
+ chunks = int(chunk_count)
1215
+ candidates = int(max_candidates)
1216
+ generated_chunks = int(max_generated_chunks)
1217
+ except (TypeError, ValueError, OverflowError) as error:
1218
+ raise ValueError("candidate budget values must be integers") from error
1219
+ if chunks <= 0:
1220
+ raise ValueError("chunk_count must be a positive integer")
1221
+ if candidates <= 0 or candidates > 10:
1222
+ raise ValueError("max_candidates must be between 1 and 10")
1223
+ if generated_chunks <= 0:
1224
+ raise ValueError("max_generated_chunks must be positive")
1225
+ if chunks > generated_chunks:
1226
+ raise ValueError("one trajectory exceeds the generated-chunk budget")
1227
+ return min(candidates, generated_chunks // chunks)
1228
+
1229
+
1230
+ @dataclass(frozen=True)
1231
+ class _VerifiedTrajectoryCandidate:
1232
+ candidate_index: int
1233
+ seed: int
1234
+ trajectory: Any
1235
+ verification: TrajectoryGateResult
1236
+
1237
+
1238
+ def _whole_trajectory_result(
1239
+ candidate: _VerifiedTrajectoryCandidate,
1240
+ attempted_seeds: Sequence[int],
1241
+ chunk_count: int,
1242
+ ) -> CascadeResult:
1243
+ return CascadeResult(
1244
+ trajectory=candidate.trajectory,
1245
+ verification=candidate.verification,
1246
+ seed=candidate.seed,
1247
+ candidate_index=candidate.candidate_index,
1248
+ attempted_seeds=tuple(attempted_seeds),
1249
+ chunk_candidate_indices=(candidate.candidate_index,) * chunk_count,
1250
+ chunk_seeds=(candidate.seed,) * chunk_count,
1251
+ selection_mode="whole_trajectory",
1252
+ )
1253
+
1254
+
1255
+ def _sequence_fallback_results(
1256
+ candidates: Sequence[_VerifiedTrajectoryCandidate],
1257
+ attempted_seeds: Sequence[int],
1258
+ chunk_count: int,
1259
+ *,
1260
+ max_paths: int,
1261
+ ) -> tuple[CascadeResult, ...]:
1262
+ """Rank mixed-seed paths using only independently gate-safe chunks."""
1263
+
1264
+ if chunk_count <= 1 or not candidates:
1265
+ return ()
1266
+ local_scores: list[list[float]] = [[] for _ in range(chunk_count)]
1267
+ usable: list[bool] = []
1268
+ for candidate in candidates:
1269
+ verification = candidate.verification
1270
+ try:
1271
+ trajectory_length = len(candidate.trajectory)
1272
+ except TypeError:
1273
+ trajectory_length = -1
1274
+ candidate_usable = bool(
1275
+ trajectory_length == chunk_count
1276
+ and len(verification.candidate_results) == chunk_count
1277
+ and len(verification.chunk_artifacts) == chunk_count
1278
+ )
1279
+ usable.append(candidate_usable)
1280
+ for chunk_index in range(chunk_count):
1281
+ score = math.inf
1282
+ if candidate_usable:
1283
+ result = verification.candidate_results[chunk_index]
1284
+ artifact = verification.chunk_artifacts[chunk_index]
1285
+ if result.passed and math.isfinite(result.score) and result.score >= 0.0:
1286
+ speaker_artifact_valid = True
1287
+ if result.speaker_gate_applied:
1288
+ try:
1289
+ speaker_artifact_valid = bool(
1290
+ artifact.speaker_embedding is not None
1291
+ and cosine_similarity(
1292
+ artifact.speaker_embedding,
1293
+ artifact.speaker_embedding,
1294
+ ) >= 1.0 - 1.0e-6
1295
+ )
1296
+ except ValueError:
1297
+ speaker_artifact_valid = False
1298
+ if speaker_artifact_valid:
1299
+ score = result.score
1300
+ local_scores[chunk_index].append(score)
1301
+
1302
+ transitions: list[list[list[float]]] = []
1303
+ for chunk_index in range(1, chunk_count):
1304
+ matrix: list[list[float]] = []
1305
+ for previous_position, previous_candidate in enumerate(candidates):
1306
+ row: list[float] = []
1307
+ for current_position, current_candidate in enumerate(candidates):
1308
+ score = math.inf
1309
+ if usable[previous_position] and usable[current_position]:
1310
+ score = candidate_chunk_transition_score(
1311
+ previous_candidate.verification.candidate_results[chunk_index - 1],
1312
+ previous_candidate.verification.chunk_artifacts[chunk_index - 1],
1313
+ current_candidate.verification.candidate_results[chunk_index],
1314
+ current_candidate.verification.chunk_artifacts[chunk_index],
1315
+ )
1316
+ row.append(score)
1317
+ matrix.append(row)
1318
+ transitions.append(matrix)
1319
+
1320
+ selections = select_k_candidate_sequences(
1321
+ local_scores,
1322
+ transitions,
1323
+ max_paths=max_paths,
1324
+ )
1325
+ output: list[CascadeResult] = []
1326
+ for rank, selection in enumerate(selections, 1):
1327
+ selected_candidates = tuple(
1328
+ candidates[position]
1329
+ for position in selection.candidate_indices
1330
+ )
1331
+ selected_trajectory = tuple(
1332
+ candidate.trajectory[chunk_index]
1333
+ for chunk_index, candidate in enumerate(selected_candidates)
1334
+ )
1335
+ selected_results = tuple(
1336
+ candidate.verification.candidate_results[chunk_index]
1337
+ for chunk_index, candidate in enumerate(selected_candidates)
1338
+ )
1339
+ selected_artifacts = tuple(
1340
+ candidate.verification.chunk_artifacts[chunk_index]
1341
+ for chunk_index, candidate in enumerate(selected_candidates)
1342
+ )
1343
+ verification = TrajectoryGateResult(
1344
+ passed=True,
1345
+ candidate_results=selected_results,
1346
+ score=selection.total_score,
1347
+ rejection_reasons=(),
1348
+ chunk_artifacts=selected_artifacts,
1349
+ )
1350
+ output.append(
1351
+ CascadeResult(
1352
+ trajectory=selected_trajectory,
1353
+ verification=verification,
1354
+ seed=None,
1355
+ candidate_index=None,
1356
+ attempted_seeds=tuple(attempted_seeds),
1357
+ chunk_candidate_indices=tuple(
1358
+ candidate.candidate_index for candidate in selected_candidates
1359
+ ),
1360
+ chunk_seeds=tuple(
1361
+ candidate.seed for candidate in selected_candidates
1362
+ ),
1363
+ selection_mode="sequence_dp",
1364
+ sequence_path_rank=rank,
1365
+ )
1366
+ )
1367
+ return tuple(output)
1368
+
1369
+
1370
+ def _preferred_speaker_verification(
1371
+ verification: TrajectoryGateResult,
1372
+ *,
1373
+ min_similarity: float,
1374
+ max_boundary_drop: float,
1375
+ ) -> bool:
1376
+ if verification.passed is not True or not math.isfinite(verification.score):
1377
+ return False
1378
+ for result in verification.candidate_results:
1379
+ if not result.speaker_gate_applied:
1380
+ continue
1381
+ similarity = _finite_float(result.speaker_similarity, minimum=-1.0, maximum=1.0)
1382
+ boundary_drop = _finite_float(result.boundary_speaker_drop, minimum=0.0)
1383
+ if (
1384
+ similarity is None
1385
+ or boundary_drop is None
1386
+ or similarity < min_similarity
1387
+ or boundary_drop > max_boundary_drop
1388
+ ):
1389
+ return False
1390
+ return True
1391
+
1392
+
1393
+ def run_adaptive_cascade(
1394
+ chunks: Sequence[str],
1395
+ root_seed: int,
1396
+ candidate_generator: Callable[[tuple[str, ...], int], Any],
1397
+ candidate_verifier: Callable[[Any, tuple[str, ...], int], TrajectoryGateResult],
1398
+ *,
1399
+ initial_candidates: int = 1,
1400
+ max_candidates: int = 5,
1401
+ preferred_min_speaker_similarity: float = 0.25,
1402
+ preferred_max_boundary_speaker_drop: float = 0.05,
1403
+ sequence_final_verifier: (
1404
+ Callable[[CascadeResult, tuple[str, ...]], TrajectoryGateResult] | None
1405
+ ) = None,
1406
+ max_sequence_paths: int = 3,
1407
+ ) -> CascadeResult:
1408
+ """Run a deterministic 1-to-5-to-10 fail-closed trajectory cascade.
1409
+
1410
+ The generator is called once per trajectory with ``root_seed + offset``.
1411
+ It receives all chunks in one call, making the shared per-trajectory seed
1412
+ contract explicit and preventing accidental per-chunk seed drift.
1413
+ """
1414
+
1415
+ try:
1416
+ chunk_tuple = tuple(str(chunk) for chunk in chunks)
1417
+ base_seed = int(root_seed)
1418
+ first_stage = int(initial_candidates)
1419
+ limit = int(max_candidates)
1420
+ except (TypeError, ValueError, OverflowError) as error:
1421
+ raise ValueError("invalid adaptive cascade arguments") from error
1422
+ if not chunk_tuple or any(not chunk for chunk in chunk_tuple):
1423
+ raise ValueError("adaptive cascade requires non-empty text chunks")
1424
+ if first_stage != 1:
1425
+ raise ValueError("online adaptive cascade must start with exactly one candidate")
1426
+ if limit < first_stage or limit > 10:
1427
+ raise ValueError("adaptive cascade supports between 1 and 10 candidates")
1428
+ if isinstance(max_sequence_paths, (bool, np.bool_)):
1429
+ raise ValueError("max_sequence_paths must be an integer between 1 and 3")
1430
+ try:
1431
+ sequence_path_limit = operator.index(max_sequence_paths)
1432
+ except (TypeError, ValueError, OverflowError) as error:
1433
+ raise ValueError(
1434
+ "max_sequence_paths must be an integer between 1 and 3"
1435
+ ) from error
1436
+ if not 1 <= sequence_path_limit <= 3:
1437
+ raise ValueError("max_sequence_paths must be an integer between 1 and 3")
1438
+ if sequence_final_verifier is not None and not callable(sequence_final_verifier):
1439
+ raise ValueError("sequence_final_verifier must be callable")
1440
+ preferred_similarity = _finite_float(
1441
+ preferred_min_speaker_similarity,
1442
+ minimum=-1.0,
1443
+ maximum=1.0,
1444
+ )
1445
+ preferred_boundary = _finite_float(
1446
+ preferred_max_boundary_speaker_drop,
1447
+ minimum=0.0,
1448
+ )
1449
+ if preferred_similarity is None or preferred_boundary is None:
1450
+ raise ValueError("preferred speaker thresholds must be finite")
1451
+
1452
+ attempted_seeds: list[int] = []
1453
+ candidates: list[_VerifiedTrajectoryCandidate] = []
1454
+
1455
+ first_seed = base_seed
1456
+ first_trajectory = candidate_generator(chunk_tuple, first_seed)
1457
+ first_verification = candidate_verifier(first_trajectory, chunk_tuple, first_seed)
1458
+ attempted_seeds.append(first_seed)
1459
+ if not isinstance(first_verification, TrajectoryGateResult):
1460
+ raise TypeError("candidate_verifier must return TrajectoryGateResult")
1461
+ first_candidate = _VerifiedTrajectoryCandidate(
1462
+ candidate_index=0,
1463
+ seed=first_seed,
1464
+ trajectory=first_trajectory,
1465
+ verification=first_verification,
1466
+ )
1467
+ candidates.append(first_candidate)
1468
+ if (
1469
+ first_verification.passed
1470
+ and math.isfinite(first_verification.score)
1471
+ and (
1472
+ limit == 1
1473
+ or _preferred_speaker_verification(
1474
+ first_verification,
1475
+ min_similarity=preferred_similarity,
1476
+ max_boundary_drop=preferred_boundary,
1477
+ )
1478
+ )
1479
+ ):
1480
+ return _whole_trajectory_result(
1481
+ first_candidate,
1482
+ attempted_seeds,
1483
+ len(chunk_tuple),
1484
+ )
1485
+
1486
+ stages = [min(5, limit)]
1487
+ if limit > 5:
1488
+ stages.append(limit)
1489
+ next_candidate = first_stage
1490
+ for stage_size in stages:
1491
+ for candidate_index in range(next_candidate, stage_size):
1492
+ seed = base_seed + candidate_index
1493
+ trajectory = candidate_generator(chunk_tuple, seed)
1494
+ verification = candidate_verifier(trajectory, chunk_tuple, seed)
1495
+ attempted_seeds.append(seed)
1496
+ if not isinstance(verification, TrajectoryGateResult):
1497
+ raise TypeError("candidate_verifier must return TrajectoryGateResult")
1498
+ candidates.append(
1499
+ _VerifiedTrajectoryCandidate(
1500
+ candidate_index=candidate_index,
1501
+ seed=seed,
1502
+ trajectory=trajectory,
1503
+ verification=verification,
1504
+ )
1505
+ )
1506
+ qualified = [
1507
+ candidate
1508
+ for candidate in candidates
1509
+ if candidate.verification.passed
1510
+ and math.isfinite(candidate.verification.score)
1511
+ ]
1512
+ final_stage = stage_size == limit
1513
+ if qualified:
1514
+ preferred_qualified = [
1515
+ candidate
1516
+ for candidate in qualified
1517
+ if _preferred_speaker_verification(
1518
+ candidate.verification,
1519
+ min_similarity=preferred_similarity,
1520
+ max_boundary_drop=preferred_boundary,
1521
+ )
1522
+ ]
1523
+ selectable = qualified if final_stage else preferred_qualified
1524
+ if selectable:
1525
+ selected_whole = min(
1526
+ selectable,
1527
+ key=lambda candidate: (
1528
+ candidate.verification.score,
1529
+ candidate.candidate_index,
1530
+ ),
1531
+ )
1532
+ return _whole_trajectory_result(
1533
+ selected_whole,
1534
+ attempted_seeds,
1535
+ len(chunk_tuple),
1536
+ )
1537
+ next_candidate = stage_size
1538
+ continue
1539
+ # With a final-aware callback, defer DP until all whole-trajectory
1540
+ # candidates in the request budget have been exhausted. This keeps
1541
+ # whole trajectories globally preferred and bounds joined checks to
1542
+ # at most ``max_sequence_paths`` once per request.
1543
+ if sequence_final_verifier is not None and not final_stage:
1544
+ next_candidate = stage_size
1545
+ continue
1546
+ sequence_results = _sequence_fallback_results(
1547
+ candidates,
1548
+ attempted_seeds,
1549
+ len(chunk_tuple),
1550
+ max_paths=(sequence_path_limit if sequence_final_verifier else 1),
1551
+ )
1552
+ if sequence_final_verifier is None:
1553
+ sequence_result = sequence_results[0] if sequence_results else None
1554
+ if sequence_result is not None and (
1555
+ final_stage
1556
+ or _preferred_speaker_verification(
1557
+ sequence_result.verification,
1558
+ min_similarity=preferred_similarity,
1559
+ max_boundary_drop=preferred_boundary,
1560
+ )
1561
+ ):
1562
+ return sequence_result
1563
+ else:
1564
+ for checked_count, sequence_result in enumerate(sequence_results, 1):
1565
+ try:
1566
+ final_verification = sequence_final_verifier(
1567
+ sequence_result,
1568
+ chunk_tuple,
1569
+ )
1570
+ except Exception as error:
1571
+ raise RuntimeError(
1572
+ "sequence final verification failed; refusing unverified audio"
1573
+ ) from error
1574
+ if not isinstance(final_verification, TrajectoryGateResult):
1575
+ raise RuntimeError(
1576
+ "sequence final verifier returned an invalid result"
1577
+ )
1578
+ if (
1579
+ final_verification.passed is True
1580
+ and math.isfinite(final_verification.score)
1581
+ and len(final_verification.candidate_results) == 1
1582
+ and isinstance(
1583
+ final_verification.candidate_results[0],
1584
+ CandidateGateResult,
1585
+ )
1586
+ and final_verification.candidate_results[0].passed is True
1587
+ and math.isfinite(
1588
+ final_verification.candidate_results[0].score
1589
+ )
1590
+ and not final_verification.rejection_reasons
1591
+ ):
1592
+ return CascadeResult(
1593
+ trajectory=sequence_result.trajectory,
1594
+ verification=sequence_result.verification,
1595
+ seed=sequence_result.seed,
1596
+ candidate_index=sequence_result.candidate_index,
1597
+ attempted_seeds=sequence_result.attempted_seeds,
1598
+ chunk_candidate_indices=(
1599
+ sequence_result.chunk_candidate_indices
1600
+ ),
1601
+ chunk_seeds=sequence_result.chunk_seeds,
1602
+ selection_mode=sequence_result.selection_mode,
1603
+ sequence_path_rank=sequence_result.sequence_path_rank,
1604
+ sequence_paths_checked=checked_count,
1605
+ )
1606
+ next_candidate = stage_size
1607
+
1608
+ raise NoQualifiedCandidateError(
1609
+ f"no verified TTS trajectory after {len(attempted_seeds)} candidates"
1610
+ )
requirements.txt CHANGED
@@ -1,6 +1,17 @@
1
  # torch / gradio / spaces are provided by the ZeroGPU image.
2
- git+https://github.com/OpenFormosa/Barbet.git
3
- git+https://github.com/OpenFormosa/BlueMagpie-TTS.git@ce384c8cc54efea1aaba7b9f1d7ded6c1c99aa9a
4
  huggingface_hub==0.36.0
5
- soundfile
6
- speechbrain>=1.0.3,<1.1
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # torch / gradio / spaces are provided by the ZeroGPU image.
2
+ git+https://github.com/OpenFormosa/Barbet.git@6fcd7ce4aa37f2250a3242995bef0fbc3b026ba8
 
3
  huggingface_hub==0.36.0
4
+ opencc-python-reimplemented==0.1.7
5
+ transformers==4.57.6
6
+ accelerate==1.12.0
7
+ einops==0.8.2
8
+ pydantic==2.11.10
9
+ numpy==2.3.5
10
+ scipy==1.17.1
11
+ numexpr==2.14.1
12
+ bottleneck==1.6.0
13
+ tqdm==4.68.2
14
+ safetensors==0.8.0
15
+ librosa==0.11.0
16
+ soundfile==0.14.0
17
+ speechbrain==1.0.3
tests/test_production.py CHANGED
@@ -6,12 +6,19 @@ from torch import nn
6
 
7
  from production import (
8
  StopHysteresisController,
 
 
 
9
  count_speech_units,
10
  duration_hard_stop_steps,
11
  effective_generation_cfg,
12
  endpoint_generation_plan,
13
  ensure_terminal_punctuation,
14
  finish_audio,
 
 
 
 
15
  select_generation_cps,
16
  split_leading_clause,
17
  split_text_for_tts,
@@ -21,6 +28,28 @@ from production import (
21
  )
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  class _SequenceStopHead(nn.Module):
25
  def __init__(self, probabilities):
26
  super().__init__()
@@ -206,3 +235,191 @@ def test_finish_audio_fades_endpoint_and_appends_silence():
206
  assert output.shape == (13,)
207
  np.testing.assert_allclose(output[-4:], 0.0)
208
  assert output[-5] < output[-6] < output[-7]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  from production import (
8
  StopHysteresisController,
9
+ candidate_local_score,
10
+ candidate_transition_score,
11
+ compare_asr_text,
12
  count_speech_units,
13
  duration_hard_stop_steps,
14
  effective_generation_cfg,
15
  endpoint_generation_plan,
16
  ensure_terminal_punctuation,
17
  finish_audio,
18
+ normalize_spoken_forms,
19
+ normalize_tts_eval_text,
20
+ normalize_tts_text,
21
+ select_candidate_sequence,
22
  select_generation_cps,
23
  split_leading_clause,
24
  split_text_for_tts,
 
28
  )
29
 
30
 
31
+ def test_eval_only_pronoun_homophones_do_not_change_model_input_text():
32
+ text = "她提醒妳,它、牠和祂都在這裡,再把她的東西放得穩穩地。"
33
+
34
+ assert normalize_tts_text(text) == text
35
+ assert normalize_tts_eval_text("她提醒妳,它、牠和祂都在這裡。") == (
36
+ "他提醒你,他、他和他都在這裡。"
37
+ )
38
+ assert normalize_tts_eval_text("的地得在再") == "的地得在再"
39
+
40
+
41
+ def test_asr_comparison_accepts_only_verified_pronoun_homophones():
42
+ comparison = compare_asr_text("她提醒我", "他提醒我")
43
+
44
+ assert comparison.passed
45
+ assert comparison.cer == 0.0
46
+ assert comparison.prefix_cer == 0.0
47
+ assert comparison.suffix_cer == 0.0
48
+ assert comparison.extra_tail_units == 0
49
+
50
+ assert not compare_asr_text("他在這裡", "他再這裡").passed
51
+
52
+
53
  class _SequenceStopHead(nn.Module):
54
  def __init__(self, probabilities):
55
  super().__init__()
 
235
  assert output.shape == (13,)
236
  np.testing.assert_allclose(output[-4:], 0.0)
237
  assert output[-5] < output[-6] < output[-7]
238
+
239
+
240
+ def test_spoken_form_normalizer_expands_common_zh_tw_forms():
241
+ text = "日期 2026/07/15,成長 12.5%,距離 10 km,顯卡 RTX 5090。"
242
+ assert normalize_spoken_forms(text) == (
243
+ "日期 二零二六年七月十五日,成長 百分之十二點五,"
244
+ "距離 十公里,顯卡 R T X 五零九零。"
245
+ )
246
+ assert normalize_spoken_forms("2026年7月16日,RTX5090") == (
247
+ "二零二六年七月十六日,R T X 五零九零"
248
+ )
249
+
250
+
251
+ def test_spoken_form_normalizer_expands_clock_time_and_spells_urls():
252
+ assert normalize_spoken_forms("時間是 15:30,午夜是 00:00。") == (
253
+ "時間是 十五點三十分,午夜是 零點整。"
254
+ )
255
+ assert normalize_spoken_forms("無效時間 25:30") == "無效時間 25:30"
256
+ assert normalize_spoken_forms("IP 192.168.1.1,網址 https://example.test:30/path") == (
257
+ "I P 192.168.1.1,網址 H T T P S 冒號 斜線 斜線 "
258
+ "example 點 test 冒號 三零 斜線 path"
259
+ )
260
+
261
+
262
+ def test_asr_comparison_unifies_traditional_and_simplified_chinese():
263
+ comparison = compare_asr_text("今天天氣真好。", "今天天气真好")
264
+
265
+ assert comparison.target_text == comparison.transcript_text == "今天天气真好"
266
+ assert comparison.cer == 0.0
267
+ assert comparison.passed is True
268
+ assert normalize_spoken_forms("溫度 -3.5 °C,頻率 3.2 GHz。") == (
269
+ "溫度 負三點五攝氏度,頻率 三點二吉赫茲。"
270
+ )
271
+
272
+
273
+ def test_spoken_form_normalizer_canonicalizes_zh_measures_for_asr_comparison():
274
+ assert normalize_spoken_forms("成長 12.5%,距離 10公里,重量 1.5公斤。") == (
275
+ "成長 百分之十二點五,距離 十公里,重量 一點五公斤。"
276
+ )
277
+ comparison = compare_asr_text("距離十公里", "距離10公里")
278
+ assert comparison.passed
279
+ assert comparison.edit_distance == 0
280
+
281
+
282
+ def test_spoken_form_normalizer_expands_network_identifiers_and_versions():
283
+ assert normalize_spoken_forms(
284
+ "網址 https://api.example.com/v1/items?q=RTX-5090&n=2。"
285
+ ) == (
286
+ "網址 H T T P S 冒號 斜線 斜線 api 點 example 點 com 斜線 v 一 "
287
+ "斜線 items 問號 q 等於 R T X 橫線 五零九零 和 n 等於 二。"
288
+ )
289
+ assert normalize_spoken_forms("信箱 USER.name+tts@example.com。") == (
290
+ "信箱 U S E R 點 name 加號 tts 小老鼠 example 點 com。"
291
+ )
292
+ assert normalize_spoken_forms(
293
+ "版本 v1.2.3,候選 version 10.4.0-beta.1+build.5。"
294
+ ) == (
295
+ "版本一點二點三,候選 版本十點四點零 預發布 beta 點 一 "
296
+ "建置 build 點 五。"
297
+ )
298
+
299
+
300
+ def test_spoken_form_normalizer_expands_currency_and_preserves_ip_and_invalid_date():
301
+ assert normalize_spoken_forms("售價 NT$1,299.50、USD 12.50、100 JPY。") == (
302
+ "售價 新台幣一千二百九十九點五零、美元十二點五零、日圓一百。"
303
+ )
304
+ assert normalize_spoken_forms("IP 192.168.1.1") == "I P 192.168.1.1"
305
+ assert normalize_spoken_forms("無效日期 2026年2月30日") == (
306
+ "無效日期 2026年2月30日"
307
+ )
308
+
309
+
310
+ def test_spoken_form_normalizer_is_conservative_and_locale_aware():
311
+ text = "Use OpenAI for stable English on 2026/07/15."
312
+ assert normalize_spoken_forms(text, locale="en-US") == text
313
+ assert normalize_spoken_forms("OpenAI stable diffusion") == "OpenAI stable diffusion"
314
+ assert normalize_spoken_forms("日期 2026/02/30") == "日期 2026/02/30"
315
+ with np.testing.assert_raises(ValueError):
316
+ normalize_spoken_forms("10 km", locale="fr-FR")
317
+
318
+
319
+ def test_asr_comparison_accepts_normalized_semantic_match():
320
+ comparison = compare_asr_text(
321
+ "成長 12.5%,使用 RTX 5090。",
322
+ "成長百分之十二點五使用 R T X 五零九零",
323
+ )
324
+ assert comparison.passed
325
+ assert comparison.edit_distance == 0
326
+ assert comparison.extra_tail == ""
327
+
328
+
329
+ def test_asr_comparison_rejects_prefix_suffix_and_extra_tail_errors():
330
+ missing_prefix = compare_asr_text("第一個字不能遺漏", "一個字不能遺漏")
331
+ assert not missing_prefix.passed
332
+ assert missing_prefix.prefix_deletions == 1
333
+ assert missing_prefix.prefix_cer > 0.0
334
+
335
+ missing_suffix = compare_asr_text("句尾內容必須完整", "句尾內容必須")
336
+ assert not missing_suffix.passed
337
+ assert missing_suffix.suffix_deletions == 2
338
+ assert missing_suffix.suffix_cer > 0.0
339
+
340
+ extra_tail = compare_asr_text("內容完成", "內容完成多講兩字")
341
+ assert not extra_tail.passed
342
+ assert extra_tail.extra_tail == "多讲两字"
343
+ assert extra_tail.extra_tail_units == 4
344
+
345
+
346
+ def test_asr_comparison_fails_closed_for_empty_or_nonfinite_limits():
347
+ assert not compare_asr_text("", "任意輸出").passed
348
+ assert not compare_asr_text("目標內容", "", max_cer=1.0).passed
349
+ assert not compare_asr_text("目標內容", "目標內容", max_cer=math.nan).passed
350
+ assert not compare_asr_text("目標內容", "目標內容", prefix_units=0).passed
351
+
352
+
353
+ def test_candidate_scores_are_finite_safe_and_gate_unsafe_audio():
354
+ score = candidate_local_score(
355
+ cer=0.02,
356
+ speaker_similarity=0.8,
357
+ boundary_speaker_drop=0.01,
358
+ prefix_cer=0.0,
359
+ suffix_cer=0.0,
360
+ pace_penalty=0.03,
361
+ style_penalty=0.02,
362
+ max_cer=0.10,
363
+ min_speaker_similarity=0.5,
364
+ max_boundary_speaker_drop=0.03,
365
+ )
366
+ assert math.isfinite(score)
367
+ assert math.isclose(score, 0.081)
368
+
369
+ base = dict(cer=0.02, speaker_similarity=0.8, boundary_speaker_drop=0.01)
370
+ assert math.isinf(candidate_local_score(**base, asr_passed=False))
371
+ assert math.isinf(candidate_local_score(**base, truncated=True))
372
+ assert math.isinf(candidate_local_score(**base, extra_tail=True))
373
+ assert math.isinf(candidate_local_score(**{**base, "cer": math.nan}))
374
+ assert math.isinf(candidate_local_score(**{**base, "boundary_speaker_drop": -0.01}))
375
+ assert math.isinf(candidate_local_score(**base, max_cer=math.nan))
376
+
377
+
378
+ def test_transition_score_rejects_nan_and_invalid_similarity():
379
+ assert candidate_transition_score(
380
+ speaker_similarity=0.9,
381
+ f0_delta=0.1,
382
+ rms_delta=0.2,
383
+ ) == 0.4
384
+ assert math.isinf(
385
+ candidate_transition_score(
386
+ speaker_similarity=math.nan,
387
+ f0_delta=0.1,
388
+ rms_delta=0.2,
389
+ )
390
+ )
391
+ assert math.isinf(
392
+ candidate_transition_score(
393
+ speaker_similarity=1.1,
394
+ f0_delta=0.1,
395
+ rms_delta=0.2,
396
+ )
397
+ )
398
+
399
+
400
+ def test_sequence_selector_finds_stable_global_path():
401
+ selection = select_candidate_sequence(
402
+ local_scores=[[0.0, 0.4], [0.0, 0.1], [0.2, 0.0]],
403
+ transition_scores=[
404
+ [[2.0, 0.0], [0.0, 2.0]],
405
+ [[2.0, 0.0], [0.0, 2.0]],
406
+ ],
407
+ )
408
+ assert selection is not None
409
+ assert selection.candidate_indices == (0, 1, 0)
410
+ assert math.isclose(selection.total_score, 0.3)
411
+
412
+
413
+ def test_sequence_selector_is_deterministic_and_fail_closed():
414
+ tied = select_candidate_sequence([[0.0, 0.0]])
415
+ assert tied is not None
416
+ assert tied.candidate_indices == (0,)
417
+
418
+ skips_nan = select_candidate_sequence([[math.nan, 0.2]])
419
+ assert skips_nan is not None
420
+ assert skips_nan.candidate_indices == (1,)
421
+
422
+ assert select_candidate_sequence([[math.nan], [0.0]], [[[0.0]]]) is None
423
+ assert select_candidate_sequence([[0.0], [0.0]], [[[math.nan]]]) is None
424
+ assert select_candidate_sequence([[0.0], [0.0]], [[[0.0, 1.0]]]) is None
425
+ assert select_candidate_sequence([]) is None
tests/test_quality_runtime.py ADDED
@@ -0,0 +1,1450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from types import SimpleNamespace
3
+
4
+ import numpy as np
5
+ import pytest
6
+ import torch
7
+
8
+ from production import split_leading_clause, split_text_for_tts
9
+ from quality_runtime import (
10
+ BASE_GENERATION_POLICY,
11
+ SAFE_DURATION_GENERATION_POLICY,
12
+ WHISPER_MODEL_ID,
13
+ WHISPER_REVISION,
14
+ CandidateObservation,
15
+ CascadeResult,
16
+ ChunkCandidateArtifact,
17
+ FinalOutputRejectedError,
18
+ LazyWhisperASR,
19
+ NoQualifiedCandidateError,
20
+ TrajectoryGateResult,
21
+ WhisperRuntime,
22
+ active_voiced_duration_seconds,
23
+ candidate_chunk_transition_score,
24
+ candidate_limit_for_chunk_budget,
25
+ cosine_similarity,
26
+ generation_policy_for_candidate_offset,
27
+ load_pinned_whisper_runtime,
28
+ prepare_candidate_audio,
29
+ qualify_trajectory_with_joined_output,
30
+ require_verified_final_output,
31
+ resolve_request_seed,
32
+ run_adaptive_cascade,
33
+ select_k_candidate_sequences,
34
+ speaker_embedding_from_audio,
35
+ speaker_evidence_from_audio,
36
+ transcribe_whisper,
37
+ verify_candidate,
38
+ verify_trajectory,
39
+ )
40
+
41
+
42
+ class _Factory:
43
+ def __init__(self, value):
44
+ self.value = value
45
+ self.calls = []
46
+
47
+ def from_pretrained(self, *args, **kwargs):
48
+ self.calls.append((args, kwargs))
49
+ return self.value
50
+
51
+
52
+ def test_candidate_generation_policy_mapping_uses_safe_duration_after_offset_zero():
53
+ base = generation_policy_for_candidate_offset(0)
54
+ assert base is BASE_GENERATION_POLICY
55
+ assert (base.name, base.cjk_cps, base.ascii_cps, base.hard_stop_margin_steps) == (
56
+ "base",
57
+ 5.2,
58
+ 4.6,
59
+ 1,
60
+ )
61
+
62
+ for offset in (1, 2, 5, 9):
63
+ safe = generation_policy_for_candidate_offset(offset)
64
+ assert safe is SAFE_DURATION_GENERATION_POLICY
65
+ assert (
66
+ safe.name,
67
+ safe.cjk_cps,
68
+ safe.ascii_cps,
69
+ safe.hard_stop_margin_steps,
70
+ ) == ("safe_duration", 4.6, 4.0, 1)
71
+
72
+
73
+ @pytest.mark.parametrize("offset", [-1, 0.5, 1.0, True, None, "one"])
74
+ def test_candidate_generation_policy_mapping_rejects_invalid_offsets(offset):
75
+ with pytest.raises(ValueError, match="non-negative integer"):
76
+ generation_policy_for_candidate_offset(offset)
77
+
78
+
79
+ def test_explicit_request_seed_is_forwarded_without_calling_random_factory():
80
+ calls = []
81
+
82
+ def factory(limit):
83
+ calls.append(limit)
84
+ return 456
85
+
86
+ assert resolve_request_seed(123, factory) == 123
87
+ assert calls == []
88
+ assert resolve_request_seed(None, factory) == 456
89
+ assert calls == [2**31]
90
+
91
+
92
+ @pytest.mark.parametrize("seed", [-1, 2**31, 1.0, True, "123", None])
93
+ def test_request_seed_validation_fails_closed(seed):
94
+ factory = lambda _limit: 2**31 if seed is None else 0
95
+ with pytest.raises(ValueError, match=r"\[0, 2147483648\)"):
96
+ resolve_request_seed(seed, factory)
97
+
98
+
99
+ def test_quality_gate_accepts_only_eval_canonicalized_pronoun_homophones():
100
+ result = verify_candidate(
101
+ CandidateObservation(
102
+ target_text="她提醒我",
103
+ transcript_text="他提醒我",
104
+ audio_duration_seconds=1.0,
105
+ )
106
+ )
107
+
108
+ assert result.passed
109
+ assert result.comparison.cer == 0.0
110
+ assert result.comparison.prefix_cer == 0.0
111
+ assert result.comparison.suffix_cer == 0.0
112
+
113
+
114
+ class _FakeWhisperModel:
115
+ def __init__(self):
116
+ self.device = None
117
+ self.evaluated = False
118
+ self.generate_calls = []
119
+
120
+ def to(self, device):
121
+ self.device = torch.device(device)
122
+ return self
123
+
124
+ def eval(self):
125
+ self.evaluated = True
126
+ return self
127
+
128
+ def generate(self, features, **kwargs):
129
+ self.generate_calls.append((features, kwargs))
130
+ return torch.tensor([[1, 2, 3]]).repeat(features.shape[0], 1)
131
+
132
+
133
+ class _FakeProcessor:
134
+ def __init__(self):
135
+ self.calls = []
136
+
137
+ def __call__(self, waveform, **kwargs):
138
+ copied = (
139
+ [segment.copy() for segment in waveform]
140
+ if isinstance(waveform, list)
141
+ else waveform.copy()
142
+ )
143
+ self.calls.append((copied, kwargs))
144
+ batch_size = len(waveform) if isinstance(waveform, list) else 1
145
+ return SimpleNamespace(input_features=torch.ones(batch_size, 80, 10))
146
+
147
+ def batch_decode(self, token_ids, **kwargs):
148
+ assert all(row == [1, 2, 3] for row in token_ids.tolist())
149
+ assert kwargs == {"skip_special_tokens": True}
150
+ return [" 合成內容完整。 "] * token_ids.shape[0]
151
+
152
+
153
+ class _StatsEncoder:
154
+ def __init__(self, *, invalid=False):
155
+ self.lengths = []
156
+ self.wav_lens = []
157
+ self.invalid = invalid
158
+
159
+ def encode_batch(self, tensor, wav_lens=None):
160
+ self.lengths.append(tensor.shape[-1])
161
+ if wav_lens is None:
162
+ wav_lens = torch.ones(tensor.shape[0], device=tensor.device)
163
+ self.wav_lens.append(wav_lens.detach().cpu().numpy())
164
+ if self.invalid:
165
+ return torch.full(
166
+ (tensor.shape[0], 1, 2),
167
+ math.nan,
168
+ device=tensor.device,
169
+ )
170
+ rows = []
171
+ for row, relative_length in zip(tensor, wav_lens, strict=True):
172
+ sample_count = max(1, int(round(float(relative_length) * tensor.shape[-1])))
173
+ active = row[:sample_count]
174
+ rows.append(
175
+ torch.stack(
176
+ (
177
+ active.mean(),
178
+ active.std(unbiased=False),
179
+ active.abs().amax(),
180
+ )
181
+ )
182
+ )
183
+ return torch.stack(rows)
184
+
185
+
186
+ def _tone(sample_rate=16_000, seconds=1.0, amplitude=0.2, frequency=220.0):
187
+ timeline = np.arange(round(sample_rate * seconds), dtype=np.float32) / sample_rate
188
+ return amplitude * np.sin(2.0 * np.pi * frequency * timeline).astype(np.float32)
189
+
190
+
191
+ def _gate_result(passed, score=0.0):
192
+ return TrajectoryGateResult(
193
+ passed=passed,
194
+ candidate_results=(),
195
+ score=score if passed else math.inf,
196
+ rejection_reasons=() if passed else ("rejected",),
197
+ )
198
+
199
+
200
+ def _chunk_verification(target, transcript, *, embedding, rms_db):
201
+ observation = CandidateObservation(
202
+ target_text=target,
203
+ transcript_text=transcript,
204
+ audio_duration_seconds=2.0,
205
+ speaker_similarity=0.8,
206
+ begin_speaker_similarity=0.8,
207
+ end_speaker_similarity=0.8,
208
+ )
209
+ return observation, ChunkCandidateArtifact(
210
+ speaker_embedding=(
211
+ None if embedding is None else np.asarray(embedding, dtype=np.float32)
212
+ ),
213
+ rms_db=rms_db,
214
+ )
215
+
216
+
217
+ def _whole_speaker_verification(*, similarity, boundary_drop, passed=True):
218
+ target = "完整而且穩定的候選內容"
219
+ observation = CandidateObservation(
220
+ target_text=target,
221
+ transcript_text=target if passed else "錯誤內容",
222
+ audio_duration_seconds=2.5,
223
+ speaker_similarity=similarity,
224
+ begin_speaker_similarity=0.60,
225
+ end_speaker_similarity=0.60 - boundary_drop,
226
+ )
227
+ artifact = ChunkCandidateArtifact(
228
+ speaker_embedding=np.array([1.0, 0.0], dtype=np.float32),
229
+ rms_db=-20.0,
230
+ )
231
+ return verify_trajectory(
232
+ [observation],
233
+ chunk_artifacts=[artifact],
234
+ min_speaker_similarity=0.10,
235
+ max_boundary_speaker_drop=0.10,
236
+ )
237
+
238
+
239
+ def _joined_verification(target, transcript):
240
+ return verify_trajectory(
241
+ [
242
+ CandidateObservation(
243
+ target_text=target,
244
+ transcript_text=transcript,
245
+ audio_duration_seconds=3.0,
246
+ speaker_similarity=0.8,
247
+ begin_speaker_similarity=0.8,
248
+ end_speaker_similarity=0.8,
249
+ )
250
+ ]
251
+ )
252
+
253
+
254
+ def test_pinned_whisper_loader_uses_exact_revision_without_global_download():
255
+ processor = _FakeProcessor()
256
+ model = _FakeWhisperModel()
257
+ processor_factory = _Factory(processor)
258
+ model_factory = _Factory(model)
259
+
260
+ runtime = load_pinned_whisper_runtime(
261
+ device="cpu",
262
+ processor_factory=processor_factory,
263
+ model_factory=model_factory,
264
+ )
265
+
266
+ assert runtime.processor is processor
267
+ assert runtime.model is model
268
+ assert runtime.device == torch.device("cpu")
269
+ assert runtime.dtype == torch.float32
270
+ assert model.evaluated
271
+ assert processor_factory.calls == [
272
+ ((WHISPER_MODEL_ID,), {"revision": WHISPER_REVISION})
273
+ ]
274
+ model_args, model_kwargs = model_factory.calls[0]
275
+ assert model_args == (WHISPER_MODEL_ID,)
276
+ assert model_kwargs["revision"] == WHISPER_REVISION
277
+ assert model_kwargs["torch_dtype"] == torch.float32
278
+ assert model_kwargs["use_safetensors"] is True
279
+
280
+
281
+ def test_lazy_whisper_runtime_loads_once_and_transcription_is_deterministic():
282
+ processor = _FakeProcessor()
283
+ model = _FakeWhisperModel()
284
+ runtime = WhisperRuntime(processor, model, torch.device("cpu"), torch.float32)
285
+ loads = []
286
+ lazy_asr = LazyWhisperASR(lambda: loads.append("load") or runtime)
287
+ audio_8khz = _tone(sample_rate=8_000, seconds=0.5)
288
+
289
+ first = transcribe_whisper(audio_8khz, 8_000, lazy_asr=lazy_asr)
290
+ second = transcribe_whisper(audio_8khz, 8_000, lazy_asr=lazy_asr)
291
+
292
+ assert first == second == "合成內容完整。"
293
+ assert loads == ["load"]
294
+ assert processor.calls[0][0].shape == (8_000,)
295
+ assert processor.calls[0][1] == {
296
+ "sampling_rate": 16_000,
297
+ "return_tensors": "pt",
298
+ }
299
+ _, generation_kwargs = model.generate_calls[0]
300
+ assert generation_kwargs == {
301
+ "language": "zh",
302
+ "task": "transcribe",
303
+ "do_sample": False,
304
+ "num_beams": 1,
305
+ "max_new_tokens": 128,
306
+ }
307
+
308
+
309
+ def test_long_whisper_audio_is_batched_below_thirty_second_limit():
310
+ processor = _FakeProcessor()
311
+ model = _FakeWhisperModel()
312
+ runtime = WhisperRuntime(processor, model, torch.device("cpu"), torch.float32)
313
+ audio = np.concatenate(
314
+ (
315
+ _tone(seconds=21.0),
316
+ np.zeros(16_000, dtype=np.float32),
317
+ _tone(seconds=21.0, frequency=240.0),
318
+ np.zeros(16_000, dtype=np.float32),
319
+ _tone(seconds=21.0, frequency=260.0),
320
+ )
321
+ )
322
+
323
+ transcript = transcribe_whisper(audio, 16_000, runtime=runtime, max_new_tokens=440)
324
+
325
+ segments = processor.calls[0][0]
326
+ assert isinstance(segments, list)
327
+ assert len(segments) == 3
328
+ assert all(segment.size <= 30 * 16_000 for segment in segments)
329
+ assert transcript == "合成內容完整。 合成內容完整。 合成內容完整。"
330
+ assert len(model.generate_calls) == 1
331
+ assert model.generate_calls[0][1]["max_new_tokens"] == 440
332
+
333
+
334
+ def test_transcription_rejects_nonfinite_audio_and_conflicting_injection():
335
+ processor = _FakeProcessor()
336
+ model = _FakeWhisperModel()
337
+ runtime = WhisperRuntime(processor, model, torch.device("cpu"), torch.float32)
338
+ with pytest.raises(ValueError, match="non-finite"):
339
+ transcribe_whisper(np.array([0.0, math.nan]), 16_000, runtime=runtime)
340
+ with pytest.raises(ValueError, match="either runtime or lazy_asr"):
341
+ transcribe_whisper(
342
+ np.ones(20),
343
+ 16_000,
344
+ runtime=runtime,
345
+ lazy_asr=LazyWhisperASR(lambda: runtime),
346
+ )
347
+
348
+
349
+ def test_candidate_audio_data_or_asr_value_error_becomes_rejection_evidence():
350
+ calls = []
351
+
352
+ def transcriber(waveform, sample_rate):
353
+ calls.append((waveform.copy(), sample_rate))
354
+ return " 內容完整 "
355
+
356
+ prepared = prepare_candidate_audio(_tone(seconds=0.25), 16_000, transcriber=transcriber)
357
+ assert prepared is not None
358
+ assert prepared.transcript_text == "內容完整"
359
+ assert prepared.duration_seconds == pytest.approx(0.25)
360
+ assert len(calls) == 1
361
+
362
+ assert prepare_candidate_audio(
363
+ [0.0, math.nan],
364
+ 16_000,
365
+ transcriber=transcriber,
366
+ ) is None
367
+ assert prepare_candidate_audio(
368
+ _tone(seconds=0.25),
369
+ 16_000,
370
+ transcriber=lambda *_: (_ for _ in ()).throw(ValueError("bad candidate")),
371
+ ) is None
372
+ assert len(calls) == 1
373
+
374
+
375
+ def test_candidate_audio_infrastructure_error_propagates_fail_closed():
376
+ with pytest.raises(RuntimeError, match="ASR unavailable"):
377
+ prepare_candidate_audio(
378
+ _tone(seconds=0.25),
379
+ 16_000,
380
+ transcriber=lambda *_: (_ for _ in ()).throw(RuntimeError("ASR unavailable")),
381
+ )
382
+
383
+
384
+ def test_ecapa_ndarray_helper_trims_resamples_normalizes_and_handles_channels():
385
+ encoder = _StatsEncoder()
386
+ tone = _tone(sample_rate=8_000, seconds=1.0)
387
+ padded = np.concatenate((np.zeros(4_000), tone, np.zeros(4_000))).astype(np.float32)
388
+ stereo = np.stack((padded, padded), axis=0)
389
+
390
+ embedding = speaker_embedding_from_audio(stereo, 8_000, encoder)
391
+
392
+ assert embedding.shape == (3,)
393
+ assert np.isfinite(embedding).all()
394
+ assert np.linalg.norm(embedding) == pytest.approx(1.0, abs=1e-6)
395
+ assert 16_000 <= encoder.lengths[0] < 32_000
396
+ assert len(encoder.lengths) == 1
397
+
398
+
399
+ def test_ecapa_ndarray_helper_fails_closed_on_silence_nan_and_bad_embedding():
400
+ with pytest.raises(ValueError, match="active speech"):
401
+ speaker_embedding_from_audio(np.zeros(16_000), 16_000, _StatsEncoder())
402
+ with pytest.raises(ValueError, match="non-finite samples"):
403
+ speaker_embedding_from_audio(
404
+ np.array([0.0, math.nan], dtype=np.float32),
405
+ 16_000,
406
+ _StatsEncoder(),
407
+ )
408
+ with pytest.raises(ValueError, match="invalid embedding"):
409
+ speaker_embedding_from_audio(_tone(), 16_000, _StatsEncoder(invalid=True))
410
+
411
+
412
+ def test_cosine_and_speaker_evidence_are_finite_and_directional():
413
+ encoder = _StatsEncoder()
414
+ audio = np.concatenate((_tone(seconds=1.0), _tone(seconds=1.0, amplitude=0.1)))
415
+ anchor = speaker_embedding_from_audio(audio, 16_000, encoder)
416
+
417
+ evidence = speaker_evidence_from_audio(
418
+ audio,
419
+ 16_000,
420
+ encoder,
421
+ anchor,
422
+ edge_seconds=0.75,
423
+ )
424
+
425
+ # Anchor extraction is one call; whole/begin/end evidence is one batched
426
+ # call instead of three repeated ECAPA forwards for the same candidate.
427
+ assert len(encoder.lengths) == 2
428
+ assert encoder.wav_lens[-1].shape == (3,)
429
+ assert evidence.similarity == pytest.approx(1.0, abs=1e-5)
430
+ assert -1.0 <= evidence.begin_similarity <= 1.0
431
+ assert -1.0 <= evidence.end_similarity <= 1.0
432
+ assert evidence.boundary_drop == max(
433
+ 0.0,
434
+ evidence.begin_similarity - evidence.end_similarity,
435
+ )
436
+ assert evidence.active_duration_seconds > 1.5
437
+ assert cosine_similarity(anchor, anchor) == pytest.approx(1.0)
438
+ with pytest.raises(ValueError):
439
+ cosine_similarity([0.0, 0.0], [0.0, 0.0])
440
+
441
+
442
+ def test_long_speaker_evidence_uses_one_bounded_window_batch():
443
+ encoder = _StatsEncoder()
444
+ audio = _tone(seconds=20.0)
445
+ anchor = np.array([0.0, 0.0, 1.0], dtype=np.float32)
446
+
447
+ evidence = speaker_evidence_from_audio(audio, 16_000, encoder, anchor)
448
+
449
+ assert len(encoder.lengths) == 1
450
+ assert encoder.lengths[0] <= 3 * 16_000
451
+ assert encoder.wav_lens[0].shape == (6,)
452
+ assert evidence.active_duration_seconds == pytest.approx(20.0, abs=0.05)
453
+ assert evidence.speaker_embedding.shape == (3,)
454
+ assert math.isfinite(evidence.active_rms_db)
455
+
456
+
457
+ def test_active_union_pace_ignores_one_or_three_second_internal_silence():
458
+ def waveform(pause_seconds):
459
+ return np.concatenate(
460
+ (
461
+ _tone(seconds=0.75),
462
+ np.zeros(round(pause_seconds * 16_000), dtype=np.float32),
463
+ _tone(seconds=0.75, frequency=260.0),
464
+ )
465
+ )
466
+
467
+ one_second = active_voiced_duration_seconds(waveform(1.0), 16_000)
468
+ three_seconds = active_voiced_duration_seconds(waveform(3.0), 16_000)
469
+
470
+ assert one_second == pytest.approx(three_seconds, abs=1.0e-9)
471
+ assert 8 / one_second == pytest.approx(8 / three_seconds, abs=1.0e-9)
472
+ assert one_second < 1.6
473
+
474
+
475
+ def test_active_union_duration_controls_speaker_eligibility_at_1_5_seconds():
476
+ below = active_voiced_duration_seconds(_tone(seconds=1.49), 16_000)
477
+ boundary = active_voiced_duration_seconds(_tone(seconds=1.50), 16_000)
478
+ assert below == pytest.approx(1.49, abs=1.0e-9)
479
+ assert boundary == pytest.approx(1.50, abs=1.0e-9)
480
+
481
+ common = {
482
+ "target_text": "這是一段完整而且穩定的合成內容",
483
+ "transcript_text": "這是一段完整而且穩定的合成內容",
484
+ }
485
+ short_result = verify_candidate(
486
+ CandidateObservation(**common, audio_duration_seconds=below)
487
+ )
488
+ boundary_result = verify_candidate(
489
+ CandidateObservation(**common, audio_duration_seconds=boundary)
490
+ )
491
+ assert short_result.passed
492
+ assert not short_result.speaker_gate_applied
493
+ assert not boundary_result.passed
494
+ assert boundary_result.speaker_gate_applied
495
+ assert "missing_speaker_evidence" in boundary_result.rejection_reasons
496
+
497
+
498
+ def test_short_candidate_uses_exact_semantics_but_bypasses_speaker_hard_gate():
499
+ passed = verify_candidate(
500
+ CandidateObservation(
501
+ target_text="謝謝你",
502
+ transcript_text="謝謝你",
503
+ audio_duration_seconds=1.49,
504
+ )
505
+ )
506
+ failed = verify_candidate(
507
+ CandidateObservation(
508
+ target_text="謝謝你",
509
+ transcript_text="謝謝",
510
+ audio_duration_seconds=1.0,
511
+ )
512
+ )
513
+
514
+ assert passed.passed
515
+ assert not passed.speaker_gate_applied
516
+ assert passed.speaker_similarity is None
517
+ assert passed.score == 0.0
518
+ assert not failed.passed
519
+ assert failed.rejection_reasons == ("semantic_gate",)
520
+
521
+
522
+ def test_candidate_pace_gate_is_optional_and_fails_closed_when_enabled():
523
+ base = CandidateObservation(
524
+ target_text="內容完整",
525
+ transcript_text="內容完整",
526
+ audio_duration_seconds=1.0,
527
+ )
528
+
529
+ assert verify_candidate(base).passed
530
+ assert verify_candidate(
531
+ CandidateObservation(**{**base.__dict__, "pace_cps": 4.2}),
532
+ max_pace_cps=4.3,
533
+ ).passed
534
+ missing = verify_candidate(base, max_pace_cps=4.3)
535
+ fast = verify_candidate(
536
+ CandidateObservation(**{**base.__dict__, "pace_cps": 4.31}),
537
+ max_pace_cps=4.3,
538
+ )
539
+
540
+ assert missing.rejection_reasons == ("missing_pace_evidence",)
541
+ assert fast.rejection_reasons == ("pace_too_fast",)
542
+
543
+
544
+ def test_long_candidate_requires_speaker_evidence_and_clamps_negative_drop():
545
+ observation = CandidateObservation(
546
+ target_text="這是一段完整而且穩定的合成內容",
547
+ transcript_text="這是一段完整而且穩定的合成內容",
548
+ audio_duration_seconds=2.0,
549
+ speaker_similarity=0.70,
550
+ begin_speaker_similarity=0.50,
551
+ end_speaker_similarity=0.60,
552
+ )
553
+ result = verify_candidate(observation)
554
+
555
+ assert result.passed
556
+ assert result.speaker_gate_applied
557
+ assert result.boundary_speaker_drop == 0.0
558
+ assert result.score == pytest.approx(0.05 * 0.30)
559
+
560
+ missing = verify_candidate(
561
+ CandidateObservation(
562
+ target_text=observation.target_text,
563
+ transcript_text=observation.transcript_text,
564
+ audio_duration_seconds=2.0,
565
+ )
566
+ )
567
+ assert not missing.passed
568
+ assert "missing_speaker_evidence" in missing.rejection_reasons
569
+
570
+
571
+ def test_candidate_rejects_boundary_drop_low_similarity_truncation_and_tail():
572
+ base = dict(
573
+ target_text="這是一段完整而且穩定的合成內容",
574
+ transcript_text="這是一段完整而且穩定的合成內容",
575
+ audio_duration_seconds=2.0,
576
+ speaker_similarity=0.7,
577
+ begin_speaker_similarity=0.5,
578
+ end_speaker_similarity=0.5,
579
+ )
580
+ boundary = verify_candidate(
581
+ CandidateObservation(**{**base, "begin_speaker_similarity": 0.8})
582
+ )
583
+ similarity = verify_candidate(
584
+ CandidateObservation(**{**base, "speaker_similarity": 0.09})
585
+ )
586
+ truncation = verify_candidate(CandidateObservation(**{**base, "truncated": True}))
587
+ tail = verify_candidate(
588
+ CandidateObservation(**{**base, "transcript_text": base["target_text"] + "多講"})
589
+ )
590
+
591
+ assert boundary.rejection_reasons == ("boundary_speaker_drop",)
592
+ assert similarity.rejection_reasons == ("speaker_similarity",)
593
+ assert truncation.rejection_reasons == ("truncated",)
594
+ assert "semantic_gate" in tail.rejection_reasons
595
+ # ASR comparison canonicalizes both sides to Simplified Chinese.
596
+ assert tail.comparison.extra_tail == "多讲"
597
+
598
+
599
+ def test_candidate_and_trajectory_fail_closed_for_bad_values_or_any_bad_chunk():
600
+ good_short = CandidateObservation("內容完整", "內容完整", 1.0)
601
+ bad_short = CandidateObservation("句尾完整", "句尾", 1.0)
602
+ invalid = verify_candidate(
603
+ CandidateObservation("內容完整", "內容完整", math.nan),
604
+ max_cer=math.nan,
605
+ )
606
+ passed_trajectory = verify_trajectory([good_short, good_short])
607
+ failed_trajectory = verify_trajectory([good_short, bad_short])
608
+
609
+ assert not invalid.passed
610
+ assert "invalid_audio_duration" in invalid.rejection_reasons
611
+ assert "invalid_gate_config" in invalid.rejection_reasons
612
+ assert passed_trajectory.passed
613
+ assert passed_trajectory.score == 0.0
614
+ assert not failed_trajectory.passed
615
+ assert math.isinf(failed_trajectory.score)
616
+ assert failed_trajectory.rejection_reasons == ("chunk_1:semantic_gate",)
617
+ assert verify_trajectory([]).rejection_reasons == ("empty_trajectory",)
618
+
619
+
620
+ def test_final_whole_output_gate_rejects_post_join_regression_without_fallback():
621
+ passed = verify_trajectory(
622
+ [CandidateObservation("整段內容完整", "整段內容完整", 1.0)]
623
+ )
624
+ rejected = verify_trajectory(
625
+ [CandidateObservation("整段內容完整", "整段內容", 1.0)]
626
+ )
627
+
628
+ assert require_verified_final_output(passed) is passed
629
+ with pytest.raises(FinalOutputRejectedError, match="final output rejected"):
630
+ require_verified_final_output(rejected)
631
+ with pytest.raises(FinalOutputRejectedError, match="invalid result"):
632
+ require_verified_final_output(None)
633
+
634
+
635
+ def test_joined_output_rejection_preserves_local_results_for_sequence_dp():
636
+ observations = []
637
+ artifacts = []
638
+ for target in ("第一段完整", "第二段完整"):
639
+ observation, artifact = _chunk_verification(
640
+ target,
641
+ target,
642
+ embedding=[1.0, 0.0],
643
+ rms_db=-20.0,
644
+ )
645
+ observations.append(observation)
646
+ artifacts.append(artifact)
647
+ local = verify_trajectory(observations, chunk_artifacts=artifacts)
648
+ joined_failed = _joined_verification("第一段完整第二段完整", "第一段完整")
649
+
650
+ qualified = qualify_trajectory_with_joined_output(local, joined_failed)
651
+
652
+ assert local.passed
653
+ assert not qualified.passed
654
+ assert math.isinf(qualified.score)
655
+ assert qualified.candidate_results is local.candidate_results
656
+ assert qualified.chunk_artifacts is local.chunk_artifacts
657
+ assert qualified.rejection_reasons == ("joined_output:chunk_0:semantic_gate",)
658
+ assert all(result.passed for result in qualified.candidate_results)
659
+
660
+
661
+ def test_joined_output_qualification_returns_local_result_only_when_joined_passes():
662
+ local = verify_trajectory(
663
+ [CandidateObservation("第一段完整", "第一段完整", 1.0)]
664
+ )
665
+ joined = verify_trajectory(
666
+ [CandidateObservation("第一段完整", "第一段完整", 1.0)]
667
+ )
668
+
669
+ assert qualify_trajectory_with_joined_output(local, joined) is local
670
+
671
+
672
+ def test_k_best_sequence_paths_are_cost_ranked_stable_distinct_and_bounded():
673
+ ranked = select_k_candidate_sequences(
674
+ [[0.0, 0.2], [0.0, 0.1]],
675
+ [[[0.4, 0.0], [0.0, 0.5]]],
676
+ max_paths=3,
677
+ )
678
+
679
+ assert [selection.candidate_indices for selection in ranked] == [
680
+ (0, 1),
681
+ (1, 0),
682
+ (0, 0),
683
+ ]
684
+ assert [selection.total_score for selection in ranked] == pytest.approx(
685
+ [0.1, 0.2, 0.4]
686
+ )
687
+ assert len({selection.candidate_indices for selection in ranked}) == 3
688
+
689
+ tied = select_k_candidate_sequences(
690
+ [[0.0, 0.0], [0.0, 0.0]],
691
+ [[[0.0, 0.0], [0.0, 0.0]]],
692
+ max_paths=3,
693
+ )
694
+ assert [selection.candidate_indices for selection in tied] == [
695
+ (0, 0),
696
+ (0, 1),
697
+ (1, 0),
698
+ ]
699
+
700
+
701
+ def test_k_best_sequence_selection_fails_closed_for_malformed_or_single_chunk_graphs():
702
+ assert select_k_candidate_sequences([[0.0, 0.1]], (), max_paths=3) == ()
703
+ assert select_k_candidate_sequences([[0.0], [0.0]], (), max_paths=3) == ()
704
+ assert (
705
+ select_k_candidate_sequences(
706
+ [[0.0, 0.1], [0.0, 0.1]],
707
+ [[[0.0]]],
708
+ max_paths=3,
709
+ )
710
+ == ()
711
+ )
712
+ for invalid in (0, 4, 1.0, True):
713
+ with pytest.raises(ValueError, match="between 1 and 3"):
714
+ select_k_candidate_sequences(
715
+ [[0.0], [0.0]],
716
+ [[[0.0]]],
717
+ max_paths=invalid,
718
+ )
719
+
720
+
721
+ def _locally_safe_join_rejected_verification(chunks, seed):
722
+ # Crossed embeddings make the two mixed paths cheaper than either
723
+ # same-candidate path, giving deterministic k-best callback order.
724
+ first_candidate = seed % 2 == 0
725
+ similarities = (0.9, 0.2) if first_candidate else (0.2, 0.9)
726
+ embeddings = (
727
+ ([1.0, 0.0], [0.0, 1.0])
728
+ if first_candidate
729
+ else ([0.0, 1.0], [1.0, 0.0])
730
+ )
731
+ observations = []
732
+ artifacts = []
733
+ for target, similarity, embedding in zip(
734
+ chunks,
735
+ similarities,
736
+ embeddings,
737
+ strict=True,
738
+ ):
739
+ observations.append(
740
+ CandidateObservation(
741
+ target_text=target,
742
+ transcript_text=target,
743
+ audio_duration_seconds=2.0,
744
+ speaker_similarity=similarity,
745
+ begin_speaker_similarity=similarity,
746
+ end_speaker_similarity=similarity,
747
+ )
748
+ )
749
+ artifacts.append(
750
+ ChunkCandidateArtifact(
751
+ speaker_embedding=np.asarray(embedding, dtype=np.float32),
752
+ rms_db=-20.0,
753
+ )
754
+ )
755
+ local = verify_trajectory(observations, chunk_artifacts=artifacts)
756
+ joined_failed = _joined_verification("".join(chunks), chunks[0])
757
+ return qualify_trajectory_with_joined_output(local, joined_failed)
758
+
759
+
760
+ def test_k_best_sequence_final_callback_uses_rank_order_and_first_passing_path():
761
+ chunks = ("第一段完整", "第二段完整")
762
+ callback_paths = []
763
+
764
+ def generator(candidate_chunks, seed):
765
+ return tuple(f"seed-{seed}-chunk-{index}" for index in range(len(candidate_chunks)))
766
+
767
+ def verifier(trajectory, candidate_chunks, seed):
768
+ return _locally_safe_join_rejected_verification(candidate_chunks, seed)
769
+
770
+ def final_verifier(sequence_result, candidate_chunks):
771
+ callback_paths.append(sequence_result.chunk_candidate_indices)
772
+ transcript = (
773
+ "".join(candidate_chunks)
774
+ if sequence_result.chunk_candidate_indices == (1, 0)
775
+ else candidate_chunks[0]
776
+ )
777
+ return _joined_verification("".join(candidate_chunks), transcript)
778
+
779
+ result = run_adaptive_cascade(
780
+ chunks,
781
+ 20,
782
+ generator,
783
+ verifier,
784
+ max_candidates=2,
785
+ sequence_final_verifier=final_verifier,
786
+ max_sequence_paths=3,
787
+ )
788
+
789
+ assert callback_paths == [(0, 1), (1, 0)]
790
+ assert result.selection_mode == "sequence_dp"
791
+ assert result.chunk_candidate_indices == (1, 0)
792
+ assert result.sequence_path_rank == 2
793
+ assert result.sequence_paths_checked == 2
794
+
795
+
796
+ def test_k_best_sequence_all_paths_rejected_is_no_qualified_candidate():
797
+ chunks = ("第一段完整", "第二段完整")
798
+ callback_paths = []
799
+
800
+ def generator(candidate_chunks, seed):
801
+ return tuple(f"seed-{seed}-chunk-{index}" for index in range(len(candidate_chunks)))
802
+
803
+ def final_verifier(sequence_result, candidate_chunks):
804
+ callback_paths.append(sequence_result.chunk_candidate_indices)
805
+ return _joined_verification("".join(candidate_chunks), candidate_chunks[0])
806
+
807
+ with pytest.raises(NoQualifiedCandidateError, match="after 2 candidates"):
808
+ run_adaptive_cascade(
809
+ chunks,
810
+ 20,
811
+ generator,
812
+ lambda trajectory, candidate_chunks, seed: (
813
+ _locally_safe_join_rejected_verification(candidate_chunks, seed)
814
+ ),
815
+ max_candidates=2,
816
+ sequence_final_verifier=final_verifier,
817
+ max_sequence_paths=3,
818
+ )
819
+
820
+ assert callback_paths == [(0, 1), (1, 0), (0, 0)]
821
+ assert len(set(callback_paths)) == 3
822
+
823
+
824
+ def test_k_best_sequence_callback_exception_aborts_fail_closed():
825
+ chunks = ("第一段完整", "第二段完整")
826
+ callback_paths = []
827
+
828
+ def callback(sequence_result, candidate_chunks):
829
+ callback_paths.append(sequence_result.chunk_candidate_indices)
830
+ raise ValueError("ASR backend disappeared")
831
+
832
+ with pytest.raises(RuntimeError, match="refusing unverified audio"):
833
+ run_adaptive_cascade(
834
+ chunks,
835
+ 20,
836
+ lambda candidate_chunks, seed: tuple(candidate_chunks),
837
+ lambda trajectory, candidate_chunks, seed: (
838
+ _locally_safe_join_rejected_verification(candidate_chunks, seed)
839
+ ),
840
+ max_candidates=2,
841
+ sequence_final_verifier=callback,
842
+ )
843
+
844
+ assert callback_paths == [(0, 1)]
845
+
846
+
847
+ def test_k_best_sequence_invalid_callback_result_aborts_fail_closed():
848
+ chunks = ("第一段完整", "第二段完整")
849
+
850
+ with pytest.raises(RuntimeError, match="invalid result"):
851
+ run_adaptive_cascade(
852
+ chunks,
853
+ 20,
854
+ lambda candidate_chunks, seed: tuple(candidate_chunks),
855
+ lambda trajectory, candidate_chunks, seed: (
856
+ _locally_safe_join_rejected_verification(candidate_chunks, seed)
857
+ ),
858
+ max_candidates=2,
859
+ sequence_final_verifier=lambda *args: None,
860
+ )
861
+
862
+
863
+ def test_k_best_sequence_inconsistent_callback_evidence_never_passes():
864
+ chunks = ("第一段完整", "第二段完整")
865
+ failed_candidate = verify_candidate(
866
+ CandidateObservation("完整內容", "錯誤內容", 1.0)
867
+ )
868
+
869
+ with pytest.raises(NoQualifiedCandidateError, match="after 2 candidates"):
870
+ run_adaptive_cascade(
871
+ chunks,
872
+ 20,
873
+ lambda candidate_chunks, seed: tuple(candidate_chunks),
874
+ lambda trajectory, candidate_chunks, seed: (
875
+ _locally_safe_join_rejected_verification(candidate_chunks, seed)
876
+ ),
877
+ max_candidates=2,
878
+ sequence_final_verifier=lambda *args: TrajectoryGateResult(
879
+ passed=True,
880
+ candidate_results=(failed_candidate,),
881
+ score=0.0,
882
+ rejection_reasons=(),
883
+ ),
884
+ )
885
+
886
+
887
+ def test_sequence_final_callback_is_not_used_for_single_chunk_or_safe_whole():
888
+ callback_calls = []
889
+
890
+ with pytest.raises(NoQualifiedCandidateError):
891
+ run_adaptive_cascade(
892
+ ("單一完整段落",),
893
+ 20,
894
+ lambda candidate_chunks, seed: tuple(candidate_chunks),
895
+ lambda trajectory, candidate_chunks, seed: TrajectoryGateResult(
896
+ passed=False,
897
+ candidate_results=(
898
+ verify_candidate(
899
+ CandidateObservation(
900
+ candidate_chunks[0],
901
+ candidate_chunks[0],
902
+ 1.0,
903
+ )
904
+ ),
905
+ ),
906
+ score=math.inf,
907
+ rejection_reasons=("joined_output:semantic_gate",),
908
+ chunk_artifacts=(ChunkCandidateArtifact(rms_db=-20.0),),
909
+ ),
910
+ max_candidates=2,
911
+ sequence_final_verifier=lambda *args: callback_calls.append(args),
912
+ )
913
+ assert callback_calls == []
914
+
915
+ result = run_adaptive_cascade(
916
+ ("第一段", "第二段"),
917
+ 30,
918
+ lambda candidate_chunks, seed: tuple(candidate_chunks),
919
+ lambda *args: _gate_result(True, 0.1),
920
+ max_candidates=2,
921
+ sequence_final_verifier=lambda *args: callback_calls.append(args),
922
+ )
923
+ assert result.selection_mode == "whole_trajectory"
924
+ assert callback_calls == []
925
+
926
+
927
+ def test_local_pass_join_fail_expands_and_selects_next_joined_safe_whole():
928
+ calls = []
929
+
930
+ def generator(chunks, seed):
931
+ calls.append(seed)
932
+ return tuple(f"seed-{seed}-{chunk}" for chunk in chunks)
933
+
934
+ def verifier(trajectory, chunks, seed):
935
+ observations = []
936
+ artifacts = []
937
+ for target in chunks:
938
+ observation, artifact = _chunk_verification(
939
+ target,
940
+ target,
941
+ embedding=[1.0, 0.0],
942
+ rms_db=-20.0,
943
+ )
944
+ observations.append(observation)
945
+ artifacts.append(artifact)
946
+ local = verify_trajectory(observations, chunk_artifacts=artifacts)
947
+ joined_transcript = "".join(chunks) if seed == 11 else chunks[0]
948
+ joined = _joined_verification("".join(chunks), joined_transcript)
949
+ return qualify_trajectory_with_joined_output(local, joined)
950
+
951
+ result = run_adaptive_cascade(
952
+ ("第一段完整", "第二段完整"),
953
+ 10,
954
+ generator,
955
+ verifier,
956
+ max_candidates=2,
957
+ )
958
+
959
+ assert calls == [10, 11]
960
+ assert result.selection_mode == "whole_trajectory"
961
+ assert result.candidate_index == 1
962
+ assert result.seed == 11
963
+
964
+
965
+ def test_all_local_pass_join_fail_can_only_return_as_sequence_dp():
966
+ chunks = ("第一段完整", "第二段完整")
967
+
968
+ def generator(candidate_chunks, seed):
969
+ return tuple(f"seed-{seed}-{chunk}" for chunk in candidate_chunks)
970
+
971
+ def verifier(trajectory, candidate_chunks, seed):
972
+ similarities = (0.9, 0.2) if seed == 20 else (0.2, 0.9)
973
+ observations = []
974
+ artifacts = []
975
+ for target, similarity in zip(candidate_chunks, similarities, strict=True):
976
+ observations.append(
977
+ CandidateObservation(
978
+ target_text=target,
979
+ transcript_text=target,
980
+ audio_duration_seconds=2.0,
981
+ speaker_similarity=similarity,
982
+ begin_speaker_similarity=similarity,
983
+ end_speaker_similarity=similarity,
984
+ )
985
+ )
986
+ artifacts.append(
987
+ ChunkCandidateArtifact(
988
+ speaker_embedding=np.array([1.0, 0.0], dtype=np.float32),
989
+ rms_db=-20.0,
990
+ )
991
+ )
992
+ local = verify_trajectory(observations, chunk_artifacts=artifacts)
993
+ joined = _joined_verification("".join(candidate_chunks), candidate_chunks[0])
994
+ return qualify_trajectory_with_joined_output(local, joined)
995
+
996
+ result = run_adaptive_cascade(
997
+ chunks,
998
+ 20,
999
+ generator,
1000
+ verifier,
1001
+ max_candidates=2,
1002
+ )
1003
+
1004
+ assert result.selection_mode == "sequence_dp"
1005
+ assert result.candidate_index is None
1006
+ assert result.seed is None
1007
+ assert result.chunk_candidate_indices == (0, 1)
1008
+ assert result.attempted_seeds == (20, 21)
1009
+
1010
+
1011
+ def test_adaptive_cascade_returns_first_candidate_immediately():
1012
+ generation_calls = []
1013
+ verification_calls = []
1014
+
1015
+ def generator(chunks, seed):
1016
+ generation_calls.append((chunks, seed))
1017
+ return tuple((chunk, seed) for chunk in chunks)
1018
+
1019
+ def verifier(trajectory, chunks, seed):
1020
+ verification_calls.append((trajectory, chunks, seed))
1021
+ assert all(chunk_seed == seed for _, chunk_seed in trajectory)
1022
+ return _gate_result(True, 0.2)
1023
+
1024
+ result = run_adaptive_cascade(
1025
+ ["第一段", "第二段"],
1026
+ 12345,
1027
+ generator,
1028
+ verifier,
1029
+ )
1030
+
1031
+ assert isinstance(result, CascadeResult)
1032
+ assert result.seed == 12345
1033
+ assert result.candidate_index == 0
1034
+ assert result.attempted_seeds == (12345,)
1035
+ assert generation_calls == [(("第一段", "第二段"), 12345)]
1036
+ assert len(verification_calls) == 1
1037
+
1038
+
1039
+ def test_marginal_stage_one_speaker_pass_expands_to_preferred_candidate():
1040
+ calls = []
1041
+
1042
+ def generator(chunks, seed):
1043
+ calls.append(seed)
1044
+ return (chunks[0], seed)
1045
+
1046
+ def verifier(trajectory, chunks, seed):
1047
+ if seed == 100:
1048
+ return _whole_speaker_verification(
1049
+ similarity=0.20,
1050
+ boundary_drop=0.04,
1051
+ )
1052
+ if seed == 105:
1053
+ return _whole_speaker_verification(
1054
+ similarity=0.35,
1055
+ boundary_drop=0.02,
1056
+ )
1057
+ return _whole_speaker_verification(
1058
+ similarity=0.30,
1059
+ boundary_drop=0.02,
1060
+ passed=False,
1061
+ )
1062
+
1063
+ result = run_adaptive_cascade(
1064
+ ["完整而且穩定的候選內容"],
1065
+ 100,
1066
+ generator,
1067
+ verifier,
1068
+ max_candidates=10,
1069
+ )
1070
+
1071
+ assert calls == list(range(100, 110))
1072
+ assert result.selection_mode == "whole_trajectory"
1073
+ assert result.candidate_index == 5
1074
+ assert result.seed == 105
1075
+
1076
+
1077
+ def test_final_stage_can_return_marginal_hard_pass_to_preserve_availability():
1078
+ calls = []
1079
+
1080
+ def generator(chunks, seed):
1081
+ calls.append(seed)
1082
+ return (chunks[0], seed)
1083
+
1084
+ def verifier(trajectory, chunks, seed):
1085
+ return _whole_speaker_verification(
1086
+ similarity=0.20,
1087
+ boundary_drop=0.08,
1088
+ passed=seed == 200,
1089
+ )
1090
+
1091
+ result = run_adaptive_cascade(
1092
+ ["完整而且穩定的候選內容"],
1093
+ 200,
1094
+ generator,
1095
+ verifier,
1096
+ max_candidates=2,
1097
+ )
1098
+
1099
+ assert calls == [200, 201]
1100
+ assert result.seed == 200
1101
+ assert result.candidate_index == 0
1102
+ assert result.verification.passed
1103
+
1104
+
1105
+ def test_adaptive_cascade_expands_to_five_and_selects_lowest_verified_score():
1106
+ calls = []
1107
+ scores = {
1108
+ 100: None,
1109
+ 101: 0.4,
1110
+ 102: None,
1111
+ 103: 0.1,
1112
+ 104: 0.1,
1113
+ }
1114
+
1115
+ def generator(chunks, seed):
1116
+ calls.append((chunks, seed))
1117
+ return {"chunks": tuple((chunk, seed) for chunk in chunks), "seed": seed}
1118
+
1119
+ def verifier(trajectory, chunks, seed):
1120
+ assert trajectory["seed"] == seed
1121
+ assert all(chunk_seed == seed for _, chunk_seed in trajectory["chunks"])
1122
+ score = scores[seed]
1123
+ return _gate_result(score is not None, score or 0.0)
1124
+
1125
+ result = run_adaptive_cascade(
1126
+ ["第一段", "第二段"],
1127
+ 100,
1128
+ generator,
1129
+ verifier,
1130
+ )
1131
+
1132
+ assert [seed for _, seed in calls] == [100, 101, 102, 103, 104]
1133
+ assert result.seed == 103
1134
+ assert result.candidate_index == 3
1135
+ assert result.verification.score == 0.1
1136
+ assert result.attempted_seeds == (100, 101, 102, 103, 104)
1137
+
1138
+
1139
+ def test_adaptive_cascade_uses_ten_only_when_stage_five_has_no_verified_candidate():
1140
+ calls = []
1141
+
1142
+ def generator(chunks, seed):
1143
+ calls.append(seed)
1144
+ return (chunks, seed)
1145
+
1146
+ def verifier(trajectory, chunks, seed):
1147
+ return _gate_result(seed in {107, 109}, score=float(110 - seed))
1148
+
1149
+ result = run_adaptive_cascade(
1150
+ ["完整內容"],
1151
+ 100,
1152
+ generator,
1153
+ verifier,
1154
+ max_candidates=10,
1155
+ )
1156
+
1157
+ assert result.seed == 109
1158
+ assert result.candidate_index == 9
1159
+ assert result.attempted_seeds == tuple(range(100, 110))
1160
+ assert calls == list(range(100, 110))
1161
+
1162
+
1163
+ def test_sequence_fallback_can_take_a_prefix_from_a_and_suffix_from_b():
1164
+ chunks = ("第一段完整", "第二段完整")
1165
+ trajectories = {
1166
+ 10: ("audio-a0", "audio-a1"),
1167
+ 11: ("audio-b0", "audio-b1"),
1168
+ }
1169
+
1170
+ def generator(candidate_chunks, seed):
1171
+ assert candidate_chunks == chunks
1172
+ return trajectories[seed]
1173
+
1174
+ def verifier(trajectory, candidate_chunks, seed):
1175
+ transcripts = (
1176
+ candidate_chunks
1177
+ if seed == 999
1178
+ else (
1179
+ (candidate_chunks[0], "錯誤內容")
1180
+ if seed == 10
1181
+ else ("錯誤內容", candidate_chunks[1])
1182
+ )
1183
+ )
1184
+ observations = []
1185
+ artifacts = []
1186
+ for target, transcript in zip(candidate_chunks, transcripts, strict=True):
1187
+ observation, artifact = _chunk_verification(
1188
+ target,
1189
+ transcript,
1190
+ embedding=[1.0, 0.0],
1191
+ rms_db=-20.0,
1192
+ )
1193
+ observations.append(observation)
1194
+ artifacts.append(artifact)
1195
+ return verify_trajectory(observations, chunk_artifacts=artifacts)
1196
+
1197
+ result = run_adaptive_cascade(
1198
+ chunks,
1199
+ 10,
1200
+ generator,
1201
+ verifier,
1202
+ max_candidates=2,
1203
+ )
1204
+
1205
+ assert result.selection_mode == "sequence_dp"
1206
+ assert result.seed is None
1207
+ assert result.candidate_index is None
1208
+ assert result.trajectory == ("audio-a0", "audio-b1")
1209
+ assert result.chunk_candidate_indices == (0, 1)
1210
+ assert result.chunk_seeds == (10, 11)
1211
+ assert result.attempted_seeds == (10, 11)
1212
+ assert result.verification.passed
1213
+ assert len(result.verification.candidate_results) == 2
1214
+ assert len(result.verification.chunk_artifacts) == 2
1215
+
1216
+
1217
+ def test_same_seed_whole_trajectory_wins_before_sequence_fallback():
1218
+ chunks = ("第一段完整", "第二段完整")
1219
+
1220
+ def generator(candidate_chunks, seed):
1221
+ return tuple(f"seed-{seed}-chunk-{index}" for index in range(len(candidate_chunks)))
1222
+
1223
+ def verifier(trajectory, candidate_chunks, seed):
1224
+ transcripts = (
1225
+ (candidate_chunks[0], "錯誤內容")
1226
+ if seed == 20
1227
+ else candidate_chunks
1228
+ )
1229
+ observations = []
1230
+ artifacts = []
1231
+ for target, transcript in zip(candidate_chunks, transcripts, strict=True):
1232
+ observation, artifact = _chunk_verification(
1233
+ target,
1234
+ transcript,
1235
+ embedding=[1.0, 0.0],
1236
+ rms_db=-20.0,
1237
+ )
1238
+ observations.append(observation)
1239
+ artifacts.append(artifact)
1240
+ return verify_trajectory(observations, chunk_artifacts=artifacts)
1241
+
1242
+ result = run_adaptive_cascade(
1243
+ chunks,
1244
+ 20,
1245
+ generator,
1246
+ verifier,
1247
+ max_candidates=2,
1248
+ )
1249
+
1250
+ assert result.selection_mode == "whole_trajectory"
1251
+ assert result.seed == 21
1252
+ assert result.candidate_index == 1
1253
+ assert result.chunk_candidate_indices == (1, 1)
1254
+ assert result.chunk_seeds == (21, 21)
1255
+ assert result.trajectory == ("seed-21-chunk-0", "seed-21-chunk-1")
1256
+
1257
+
1258
+ def test_sequence_fallback_rejects_unsafe_transition_edge():
1259
+ chunks = ("第一段完整", "第二段完整")
1260
+
1261
+ def generator(candidate_chunks, seed):
1262
+ return tuple(f"seed-{seed}-chunk-{index}" for index in range(len(candidate_chunks)))
1263
+
1264
+ def verifier(trajectory, candidate_chunks, seed):
1265
+ transcripts = (
1266
+ (candidate_chunks[0], "錯誤內容")
1267
+ if seed == 30
1268
+ else ("錯誤內容", candidate_chunks[1])
1269
+ )
1270
+ embeddings = (
1271
+ ([1.0, 0.0], [1.0, 0.0])
1272
+ if seed == 30
1273
+ else ([1.0, 0.0], [1.0, 0.0, 0.0])
1274
+ )
1275
+ observations = []
1276
+ artifacts = []
1277
+ for target, transcript, embedding in zip(
1278
+ candidate_chunks,
1279
+ transcripts,
1280
+ embeddings,
1281
+ strict=True,
1282
+ ):
1283
+ observation, artifact = _chunk_verification(
1284
+ target,
1285
+ transcript,
1286
+ embedding=embedding,
1287
+ rms_db=-20.0,
1288
+ )
1289
+ observations.append(observation)
1290
+ artifacts.append(artifact)
1291
+ return verify_trajectory(observations, chunk_artifacts=artifacts)
1292
+
1293
+ with pytest.raises(NoQualifiedCandidateError, match="after 2 candidates"):
1294
+ run_adaptive_cascade(
1295
+ chunks,
1296
+ 30,
1297
+ generator,
1298
+ verifier,
1299
+ max_candidates=2,
1300
+ )
1301
+
1302
+
1303
+ def test_transition_score_requires_safe_evidence_and_penalizes_rms_delta():
1304
+ first_observation, first_artifact = _chunk_verification(
1305
+ "第一段完整",
1306
+ "第一段完整",
1307
+ embedding=[1.0, 0.0],
1308
+ rms_db=-20.0,
1309
+ )
1310
+ second_observation, second_artifact = _chunk_verification(
1311
+ "第二段完整",
1312
+ "第二段完整",
1313
+ embedding=[1.0, 0.0],
1314
+ rms_db=-16.0,
1315
+ )
1316
+ first = verify_trajectory([first_observation], chunk_artifacts=[first_artifact])
1317
+ second = verify_trajectory([second_observation], chunk_artifacts=[second_artifact])
1318
+
1319
+ score = candidate_chunk_transition_score(
1320
+ first.candidate_results[0],
1321
+ first.chunk_artifacts[0],
1322
+ second.candidate_results[0],
1323
+ second.chunk_artifacts[0],
1324
+ )
1325
+ assert score == pytest.approx(0.20)
1326
+
1327
+ unsafe = ChunkCandidateArtifact(speaker_embedding=None, rms_db=-16.0)
1328
+ assert math.isinf(
1329
+ candidate_chunk_transition_score(
1330
+ first.candidate_results[0],
1331
+ first.chunk_artifacts[0],
1332
+ second.candidate_results[0],
1333
+ unsafe,
1334
+ )
1335
+ )
1336
+
1337
+
1338
+ @pytest.mark.parametrize(
1339
+ ("chunk_count", "expected_limit"),
1340
+ [(1, 10), (2, 10), (3, 6), (4, 5), (5, 4), (6, 3), (10, 2), (20, 1)],
1341
+ )
1342
+ def test_candidate_limit_obeys_twenty_generated_chunk_budget(chunk_count, expected_limit):
1343
+ limit = candidate_limit_for_chunk_budget(chunk_count)
1344
+ assert limit == expected_limit
1345
+ assert limit * chunk_count <= 20
1346
+
1347
+
1348
+ def test_candidate_limit_rejects_a_trajectory_that_already_exceeds_budget():
1349
+ with pytest.raises(ValueError, match="one trajectory exceeds"):
1350
+ candidate_limit_for_chunk_budget(21)
1351
+ with pytest.raises(ValueError, match="between 1 and 10"):
1352
+ candidate_limit_for_chunk_budget(1, max_candidates=11)
1353
+
1354
+
1355
+ def test_360_character_profiles_stay_inside_generated_chunk_budget():
1356
+ profiles = [
1357
+ "甲" * 360,
1358
+ ("甲" * 11 + "。") * 30,
1359
+ ("甲" * 40 + "。") * 8 + "乙" * 32,
1360
+ ]
1361
+ for text in profiles:
1362
+ chunks = split_text_for_tts(text, max_chars=80, min_chunk_chars=12)
1363
+ chunks = split_leading_clause(
1364
+ chunks[0],
1365
+ search_chars=40,
1366
+ min_chunk_chars=12,
1367
+ ) + chunks[1:]
1368
+ limit = candidate_limit_for_chunk_budget(len(chunks))
1369
+ assert limit * len(chunks) <= 20
1370
+
1371
+
1372
+ def test_candidate_asr_value_error_rejects_seed_and_cascade_continues():
1373
+ calls = []
1374
+
1375
+ def generator(chunks, seed):
1376
+ calls.append(seed)
1377
+ return _tone(seconds=0.25)
1378
+
1379
+ def verifier(trajectory, chunks, seed):
1380
+ prepared = prepare_candidate_audio(
1381
+ trajectory,
1382
+ 16_000,
1383
+ transcriber=(
1384
+ (lambda *_: (_ for _ in ()).throw(ValueError("candidate ASR failure")))
1385
+ if seed == 50
1386
+ else (lambda *_: chunks[0])
1387
+ ),
1388
+ )
1389
+ if prepared is None:
1390
+ return _gate_result(False)
1391
+ return verify_trajectory(
1392
+ [
1393
+ CandidateObservation(
1394
+ target_text=chunks[0],
1395
+ transcript_text=prepared.transcript_text,
1396
+ audio_duration_seconds=prepared.duration_seconds,
1397
+ )
1398
+ ]
1399
+ )
1400
+
1401
+ result = run_adaptive_cascade(
1402
+ ["完整內容"],
1403
+ 50,
1404
+ generator,
1405
+ verifier,
1406
+ max_candidates=2,
1407
+ )
1408
+
1409
+ assert result.seed == 51
1410
+ assert result.candidate_index == 1
1411
+ assert calls == [50, 51]
1412
+
1413
+
1414
+ def test_adaptive_cascade_raises_without_qualified_candidate_and_never_falls_back():
1415
+ generated = []
1416
+
1417
+ def generator(chunks, seed):
1418
+ generated.append(seed)
1419
+ return (chunks, seed)
1420
+
1421
+ with pytest.raises(NoQualifiedCandidateError, match="after 5 candidates"):
1422
+ run_adaptive_cascade(
1423
+ ["必須完整"],
1424
+ 7,
1425
+ generator,
1426
+ lambda trajectory, chunks, seed: _gate_result(False),
1427
+ )
1428
+ assert generated == [7, 8, 9, 10, 11]
1429
+
1430
+
1431
+ def test_adaptive_cascade_validates_contract_and_rejects_non_gate_verifier():
1432
+ generator = lambda chunks, seed: (chunks, seed)
1433
+ with pytest.raises(ValueError, match="start with exactly one"):
1434
+ run_adaptive_cascade(
1435
+ ["內容"],
1436
+ 1,
1437
+ generator,
1438
+ lambda *args: _gate_result(True),
1439
+ initial_candidates=2,
1440
+ )
1441
+ with pytest.raises(ValueError, match="between 1 and 10"):
1442
+ run_adaptive_cascade(
1443
+ ["內容"],
1444
+ 1,
1445
+ generator,
1446
+ lambda *args: _gate_result(True),
1447
+ max_candidates=11,
1448
+ )
1449
+ with pytest.raises(TypeError, match="TrajectoryGateResult"):
1450
+ run_adaptive_cascade(["內容"], 1, generator, lambda *args: True)
tests/test_release_pins.py CHANGED
@@ -1,4 +1,5 @@
1
  import ast
 
2
  from pathlib import Path
3
 
4
 
@@ -26,12 +27,203 @@ def test_remote_model_and_speaker_encoder_are_revision_pinned():
26
  assert constants["MODEL_REVISION"] == "aaf1a0878e37875382bb0e5c8a3a2ba43be67297"
27
  assert constants["ECAPA_REVISION"] == "0f99f2d0ebe89ac095bcc5903c4dd8f72b367286"
28
  assert "snapshot_download(REPO_ID, revision=MODEL_REVISION)" in source
29
- assert "revision=ECAPA_REVISION" in source
 
 
30
 
31
 
32
- def test_tts_runtime_dependency_is_commit_pinned():
33
  requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
34
- tts_lines = [line.strip() for line in requirements if "BlueMagpie-TTS.git" in line]
 
 
35
 
36
- assert len(tts_lines) == 1
37
- assert "@" in tts_lines[0].rsplit("/", 1)[-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import ast
2
+ import hashlib
3
  from pathlib import Path
4
 
5
 
 
27
  assert constants["MODEL_REVISION"] == "aaf1a0878e37875382bb0e5c8a3a2ba43be67297"
28
  assert constants["ECAPA_REVISION"] == "0f99f2d0ebe89ac095bcc5903c4dd8f72b367286"
29
  assert "snapshot_download(REPO_ID, revision=MODEL_REVISION)" in source
30
+ assert "snapshot_download(ECAPA_REPO_ID, revision=ECAPA_REVISION)" in source
31
+ assert "source=ECAPA_DIR" in source
32
+ assert 'overrides={"pretrained_path": ECAPA_DIR}' in source
33
 
34
 
35
+ def test_tts_runtime_is_vendored_from_the_frozen_commit():
36
  requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
37
+ provenance = (ROOT / "bluemagpie" / "UPSTREAM_RUNTIME.md").read_text(
38
+ encoding="utf-8"
39
+ )
40
 
41
+ assert not any("BlueMagpie-TTS.git" in line for line in requirements)
42
+ assert "ce384c8cc54efea1aaba7b9f1d7ded6c1c99aa9a" in provenance
43
+ assert (ROOT / "bluemagpie" / "LICENSE.upstream").is_file()
44
+ assert (ROOT / "bluemagpie" / "_vendor" / "voxcpm" / "LICENSE").is_file()
45
+ pinned_hashes = {
46
+ "model.py": "91810524212b34f727880154d90653fab4ae1b75eb3471b86cafd92c75514fef",
47
+ "loading.py": "e3407544e9bc888018fe5771edc01d954469e2af548b566873e0ef7f2afe6dca",
48
+ "_vendor/voxcpm/model/utils.py": (
49
+ "cea16e1ab57f15129a7f5dec13c428bd14a771221abcf17dd0a90b3d65e763a2"
50
+ ),
51
+ }
52
+ for relative_path, expected_hash in pinned_hashes.items():
53
+ payload = (ROOT / "bluemagpie" / relative_path).read_bytes()
54
+ assert hashlib.sha256(payload).hexdigest() == expected_hash
55
+
56
+
57
+ def test_barbet_runtime_dependency_is_commit_pinned():
58
+ requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
59
+ barbet_lines = [line for line in requirements if "OpenFormosa/Barbet.git" in line]
60
+
61
+ assert barbet_lines == [
62
+ "git+https://github.com/OpenFormosa/Barbet.git@"
63
+ "6fcd7ce4aa37f2250a3242995bef0fbc3b026ba8"
64
+ ]
65
+
66
+
67
+ def test_quality_asr_is_revision_pinned():
68
+ constants = _string_constants(ROOT / "quality_runtime.py")
69
+ app_source = (ROOT / "app.py").read_text(encoding="utf-8")
70
+
71
+ assert constants["WHISPER_MODEL_ID"] == "openai/whisper-large-v3-turbo"
72
+ assert constants["WHISPER_REVISION"] == "41f01f3fe87f28c78e2fbf8b568835947dd65ed9"
73
+ assert "snapshot_download(WHISPER_MODEL_ID, revision=WHISPER_REVISION)" in app_source
74
+
75
+
76
+ def test_quality_runtime_dependencies_are_version_pinned():
77
+ requirements = set(
78
+ (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
79
+ )
80
+
81
+ assert "huggingface_hub==0.36.0" in requirements
82
+ assert "opencc-python-reimplemented==0.1.7" in requirements
83
+ assert "transformers==4.57.6" in requirements
84
+ assert "accelerate==1.12.0" in requirements
85
+ assert "einops==0.8.2" in requirements
86
+ assert "pydantic==2.11.10" in requirements
87
+ assert "numpy==2.3.5" in requirements
88
+ assert "scipy==1.17.1" in requirements
89
+ assert "numexpr==2.14.1" in requirements
90
+ assert "bottleneck==1.6.0" in requirements
91
+ assert "tqdm==4.68.2" in requirements
92
+ assert "safetensors==0.8.0" in requirements
93
+ assert "librosa==0.11.0" in requirements
94
+ assert "soundfile==0.14.0" in requirements
95
+ assert "speechbrain==1.0.3" in requirements
96
+
97
+
98
+ def test_readme_describes_conditional_cascade_and_unwired_experimental_scores():
99
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
100
+
101
+ assert "same-seed whole trajectory first" in readme
102
+ assert "1→5→10 sequence fallback" in readme
103
+ assert "floor(20 / chunk count)" in readme
104
+ assert "at most 20 generated TTS chunks" in readme
105
+ assert "不使用 reference-style distribution score" in readme
106
+ assert "尚未啟用 normalized median-F0 transition" in readme
107
+ assert "candidate 0" in readme
108
+ assert "5.2 CJK / 4.6 ASCII" in readme
109
+ assert "candidate 1–9" in readme
110
+ assert "4.6 CJK / 4.0 ASCII" in readme
111
+
112
+
113
+ def test_app_wires_candidate_offset_to_explicit_generation_policy_and_logs_it():
114
+ source = (ROOT / "app.py").read_text(encoding="utf-8")
115
+
116
+ assert source.count("policy: GenerationPolicy") == 2
117
+ assert "policy=generation_policy_for_candidate_offset(seed - request_seed)" in source
118
+ assert 'f"name={policy.name}' in source
119
+ assert "chunk_policies={selected_policies}" in source
120
+ assert '"min_len": min_len' in source
121
+
122
+
123
+ def test_internal_synthesize_accepts_only_a_keyword_seed_while_ui_stays_unchanged():
124
+ source = (ROOT / "app.py").read_text(encoding="utf-8")
125
+ tree = ast.parse(source)
126
+ functions = {
127
+ node.name: node
128
+ for node in tree.body
129
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
130
+ }
131
+
132
+ synthesize = functions["_synthesize"]
133
+ assert "request_seed" not in [argument.arg for argument in synthesize.args.args]
134
+ assert [argument.arg for argument in synthesize.args.kwonlyargs][-1] == "request_seed"
135
+ assert isinstance(synthesize.args.kw_defaults[-1], ast.Constant)
136
+ assert synthesize.args.kw_defaults[-1].value is None
137
+ assert "request_seed = resolve_request_seed(request_seed, secrets.randbelow)" in source
138
+ assert "run_adaptive_cascade(\n chunks,\n request_seed," in source
139
+ for wrapper_name in ("tts_speaker", "tts_reference", "tts_longform"):
140
+ wrapper = functions[wrapper_name]
141
+ arguments = wrapper.args.args + wrapper.args.kwonlyargs
142
+ assert "request_seed" not in [argument.arg for argument in arguments]
143
+
144
+
145
+ def test_app_reverifies_the_post_join_speed_adjusted_whole_waveform():
146
+ source = (ROOT / "app.py").read_text(encoding="utf-8")
147
+ tree = ast.parse(source)
148
+ functions = {
149
+ node.name: node
150
+ for node in tree.body
151
+ if isinstance(node, ast.FunctionDef)
152
+ }
153
+ assemble_source = ast.get_source_segment(
154
+ source,
155
+ functions["_assemble_trajectory_audio"],
156
+ )
157
+ synthesize_source = ast.get_source_segment(source, functions["_synthesize"])
158
+
159
+ assert assemble_source is not None
160
+ assert synthesize_source is not None
161
+ speed_index = assemble_source.index(
162
+ "waveform = _apply_speed(waveform, playback_speed)"
163
+ )
164
+ finish_index = assemble_source.index("return finish_audio(waveform, SR)")
165
+ assert speed_index < finish_index
166
+ assemble_index = synthesize_source.index(
167
+ "waveform = _assemble_trajectory_audio(cascade.trajectory, chunks, speed)"
168
+ )
169
+ verify_index = synthesize_source.index("final_verification = _verify_trajectory_audio(")
170
+ require_index = synthesize_source.index(
171
+ "require_verified_final_output(final_verification)"
172
+ )
173
+ return_index = synthesize_source.index("return SR, waveform")
174
+ assert assemble_index < verify_index < require_index < return_index
175
+ assert "(text,)" in synthesize_source[verify_index:require_index]
176
+ assert " 1.0," in synthesize_source[verify_index:require_index]
177
+ assert "QUALITY_FINAL_ASR_MAX_NEW_TOKENS" in synthesize_source[
178
+ verify_index:require_index
179
+ ]
180
+
181
+
182
+ def test_whole_candidate_qualification_uses_the_exact_return_assembler_after_local_pass():
183
+ source = (ROOT / "app.py").read_text(encoding="utf-8")
184
+ tree = ast.parse(source)
185
+ functions = {
186
+ node.name: node
187
+ for node in tree.body
188
+ if isinstance(node, ast.FunctionDef)
189
+ }
190
+ qualify_source = ast.get_source_segment(
191
+ source,
192
+ functions["_qualify_candidate_trajectory_audio"],
193
+ )
194
+ synthesize_source = ast.get_source_segment(source, functions["_synthesize"])
195
+
196
+ assert qualify_source is not None
197
+ assert synthesize_source is not None
198
+ local_index = qualify_source.index("local_verification = _verify_trajectory_audio(")
199
+ local_fail_index = qualify_source.index("if not local_verification.passed:")
200
+ assemble_index = qualify_source.index("waveform = _assemble_trajectory_audio(")
201
+ joined_index = qualify_source.index("joined_verification = _verify_trajectory_audio(")
202
+ assert local_index < local_fail_index < assemble_index < joined_index
203
+ assert "QUALITY_FINAL_ASR_MAX_NEW_TOKENS" in qualify_source[joined_index:]
204
+ assert "qualify_trajectory_with_joined_output(" in qualify_source[joined_index:]
205
+ assert (
206
+ "waveform = _assemble_trajectory_audio(cascade.trajectory, chunks, speed)"
207
+ in synthesize_source
208
+ )
209
+ assert "require_verified_final_output(final_verification)" in synthesize_source
210
+
211
+
212
+ def test_space_wires_bounded_k_best_paths_to_exact_assembled_whole_gate():
213
+ source = (ROOT / "app.py").read_text(encoding="utf-8")
214
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
215
+
216
+ assert "sequence_final_verifier=lambda sequence_result, candidate_chunks:" in source
217
+ assert "_verify_sequence_trajectory_audio(" in source
218
+ assert "max_sequence_paths=3" in source
219
+ assert "waveform = _assemble_trajectory_audio(" in source
220
+ assert "QUALITY_FINAL_ASR_MAX_NEW_TOKENS" in source
221
+ assert "sequence_rank={cascade.sequence_path_rank}" in source
222
+ assert "sequence_paths_checked={cascade.sequence_paths_checked}" in source
223
+ assert "cer={comparison.cer:.6f}" in source
224
+ assert "prefix_cer={comparison.prefix_cer:.6f}" in source
225
+ assert "suffix_cer={comparison.suffix_cer:.6f}" in source
226
+ assert "tail_units={comparison.extra_tail_units}" in source
227
+ assert "最多 3 條不同完整路徑" in readme
228
+ assert "不增加 TTS generation 數量" in readme
229
+ assert "不套用於單 chunk request" in readme