Spaces:
Running on Zero
Running on Zero
Add fail-closed stable speaker inference
Browse files- README.md +77 -7
- app.py +426 -39
- bluemagpie/LICENSE.upstream +201 -0
- bluemagpie/UPSTREAM_RUNTIME.md +12 -0
- bluemagpie/__init__.py +29 -0
- bluemagpie/_vendor/__init__.py +6 -0
- bluemagpie/_vendor/voxcpm/LICENSE +201 -0
- bluemagpie/_vendor/voxcpm/PROVENANCE.md +35 -0
- bluemagpie/_vendor/voxcpm/__init__.py +17 -0
- bluemagpie/_vendor/voxcpm/model/__init__.py +14 -0
- bluemagpie/_vendor/voxcpm/model/utils.py +231 -0
- bluemagpie/_vendor/voxcpm/model/voxcpm2.py +1256 -0
- bluemagpie/_vendor/voxcpm/modules/__init__.py +0 -0
- bluemagpie/_vendor/voxcpm/modules/audiovae/__init__.py +2 -0
- bluemagpie/_vendor/voxcpm/modules/audiovae/audio_vae.py +377 -0
- bluemagpie/_vendor/voxcpm/modules/audiovae/audio_vae_v2.py +580 -0
- bluemagpie/_vendor/voxcpm/modules/layers/__init__.py +1 -0
- bluemagpie/_vendor/voxcpm/modules/layers/lora.py +130 -0
- bluemagpie/_vendor/voxcpm/modules/layers/scalar_quantization_layer.py +26 -0
- bluemagpie/_vendor/voxcpm/modules/locdit/__init__.py +3 -0
- bluemagpie/_vendor/voxcpm/modules/locdit/local_dit.py +114 -0
- bluemagpie/_vendor/voxcpm/modules/locdit/local_dit_v2.py +116 -0
- bluemagpie/_vendor/voxcpm/modules/locdit/unified_cfm.py +232 -0
- bluemagpie/_vendor/voxcpm/modules/locenc/__init__.py +1 -0
- bluemagpie/_vendor/voxcpm/modules/locenc/local_encoder.py +30 -0
- bluemagpie/_vendor/voxcpm/modules/minicpm4/__init__.py +3 -0
- bluemagpie/_vendor/voxcpm/modules/minicpm4/cache.py +47 -0
- bluemagpie/_vendor/voxcpm/modules/minicpm4/config.py +30 -0
- bluemagpie/_vendor/voxcpm/modules/minicpm4/model.py +431 -0
- bluemagpie/adapter.py +68 -0
- bluemagpie/centroid.py +67 -0
- bluemagpie/conditioning.py +37 -0
- bluemagpie/config.py +135 -0
- bluemagpie/loading.py +284 -0
- bluemagpie/model.py +717 -0
- bluemagpie/tslm.py +98 -0
- production.py +815 -1
- quality_runtime.py +1610 -0
- requirements.txt +15 -4
- tests/test_production.py +217 -0
- tests/test_quality_runtime.py +1450 -0
- 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`
|
|
|
|
|
|
|
| 37 |
避免 Space 重啟後在沒有程式版本變更的情況下改變模型或 speaker embedding 空間。
|
|
|
|
|
|
|
| 38 |
|
| 39 |
| 設定 | 值 |
|
| 40 |
|---|---:|
|
| 41 |
| CFG | 2.0 |
|
| 42 |
| NFE steps | 10 |
|
| 43 |
| Target pace | 4.0 speech units/sec |
|
| 44 |
-
|
|
|
|
|
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
| 56 |
|
| 57 |
-
每個請求會取得新的隨機 seed;
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 186 |
-
ascii_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=
|
| 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 =
|
| 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
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
)
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
)
|
| 297 |
-
|
| 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]}`
|
| 384 |
-
`{ECAPA_REVISION[:8]}`,避免服務重啟時
|
|
|
|
| 385 |
|
| 386 |
目前預設採用穩定推論設定:`CFG 2.0`(極短句最低 `3.0`)、`NFE 10`、目標語速 `4.0 字/秒`、
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 33 |
requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
assert
|
| 37 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|