Spaces:
Running on Zero
Running on Zero
Add dual-ASR whole-output verification
Browse files- README.md +12 -5
- app.py +133 -14
- quality_runtime.py +176 -18
- tests/test_quality_runtime.py +239 -0
- tests/test_release_pins.py +106 -0
README.md
CHANGED
|
@@ -39,7 +39,9 @@ speaker-reference 模式使用的 ECAPA encoder 固定在
|
|
| 39 |
Barbet 另固定在 `6fcd7ce4aa37f2250a3242995bef0fbc3b026ba8`,
|
| 40 |
避免 Space 重啟後在沒有程式版本變更的情況下改變模型或 speaker embedding 空間。
|
| 41 |
候選語意驗證固定使用 Whisper large-v3-turbo revision
|
| 42 |
-
`41f01f3fe87f28c78e2fbf8b568835947dd65ed9`
|
|
|
|
|
|
|
| 43 |
|
| 44 |
| 設定 | 值 |
|
| 45 |
|---|---:|
|
|
@@ -54,7 +56,7 @@ Barbet 另固定在 `6fcd7ce4aa37f2250a3242995bef0fbc3b026ba8`,
|
|
| 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 |
|
| 58 |
| Local speaker verification | frozen request centroid + bounded window begin/end directional drift |
|
| 59 |
| Early accept speaker preference | similarity ≥ 0.25 and directional drop ≤ 0.05 for gated chunks |
|
| 60 |
| Local hard speaker gate | similarity ≥ 0.10 and directional drop ≤ 0.10 |
|
|
@@ -85,12 +87,17 @@ margin 固定 +1 latent step、`min_len` 固定為 2,不會為了播放目標
|
|
| 85 |
以及下述唯一 boundary-only、drop 不超過 0.15 的 DP-only chunks。切段保留標點,並依逗號、分號或句末標點
|
| 86 |
插入不同長度的停頓。輸出最後會套用保守的 RMS floor 與 peak limit。
|
| 87 |
完成 RMS matching、edge fade、pause、crossfade 與使用者 speed 後,服務會再對最終整段 waveform
|
| 88 |
-
執行
|
| 89 |
-
post-join gate 失敗時不會回傳先前已通過的
|
|
|
|
|
|
|
| 90 |
此外,same-seed trajectory 只有在每個 local chunk 都通過後,才會先用同一個
|
| 91 |
`_assemble_trajectory_audio` 組成實際播放版本並跑整段 semantic/pace/speaker gate;joined gate
|
| 92 |
失敗會取消該 whole trajectory 的資格,但保留已通過的 local evidence 供 sequence DP 使用。
|
| 93 |
-
這個額外
|
|
|
|
|
|
|
|
|
|
| 94 |
若整個 candidate budget 都沒有 joined-safe whole trajectory,多 chunk request 才會進入 bounded
|
| 95 |
k-best DP。已通過 local hard gate 的 chunk 可直接進入 DP;唯一 rejection reason 是
|
| 96 |
`boundary_speaker_drop`、且 drop 不超過 0.15 的 chunk 也可作為 DP-only 候選。這個局部容忍
|
|
|
|
| 39 |
Barbet 另固定在 `6fcd7ce4aa37f2250a3242995bef0fbc3b026ba8`,
|
| 40 |
避免 Space 重啟後在沒有程式版本變更的情況下改變模型或 speaker embedding 空間。
|
| 41 |
候選語意驗證固定使用 Whisper large-v3-turbo revision
|
| 42 |
+
`41f01f3fe87f28c78e2fbf8b568835947dd65ed9`;exact-assembled whole-output
|
| 43 |
+
語意驗證另固定使用 Whisper large-v3 revision
|
| 44 |
+
`06f233fe06e710322aca913c1bc4249a0d71fce1`,兩者必須同時通過。
|
| 45 |
|
| 46 |
| 設定 | 值 |
|
| 47 |
|---|---:|
|
|
|
|
| 56 |
| Hard stop | native-pace target steps, independent of playback pace |
|
| 57 |
| Pace correction | whole completed chunk, pitch-preserving; active-region stretcher disabled |
|
| 58 |
| Pace / speaker eligibility duration | union of active 25 ms RMS frames at 10 ms hop; internal pauses excluded |
|
| 59 |
+
| Semantic verification | turbo local screen + turbo/full-large-v3 hard intersection on exact whole output; first/last 6 exact, no lexical tail; fail closed |
|
| 60 |
| Local speaker verification | frozen request centroid + bounded window begin/end directional drift |
|
| 61 |
| Early accept speaker preference | similarity ≥ 0.25 and directional drop ≤ 0.05 for gated chunks |
|
| 62 |
| Local hard speaker gate | similarity ≥ 0.10 and directional drop ≤ 0.10 |
|
|
|
|
| 87 |
以及下述唯一 boundary-only、drop 不超過 0.15 的 DP-only chunks。切段保留標點,並依逗號、分號或句末標點
|
| 88 |
插入不同長度的停頓。輸出最後會套用保守的 RMS floor 與 peak limit。
|
| 89 |
完成 RMS matching、edge fade、pause、crossfade 與使用者 speed 後,服務會再對最終整段 waveform
|
| 90 |
+
執行 normalized target 的 prefix/whole/suffix/tail、pace 與 speaker anchor/boundary gate,並要求
|
| 91 |
+
turbo 與 full large-v3 的語意 hard intersection;這個 post-join gate 失敗時不會回傳先前已通過的
|
| 92 |
+
chunk 音訊。Full large-v3 不驗 local chunk,只驗 production assembler 產生的 exact whole candidate
|
| 93 |
+
或 exact sequence path;其 rejection 會讓 cascade 繼續下一個 seed。
|
| 94 |
此外,same-seed trajectory 只有在每個 local chunk 都通過後,才會先用同一個
|
| 95 |
`_assemble_trajectory_audio` 組成實際播放版本並跑整段 semantic/pace/speaker gate;joined gate
|
| 96 |
失敗會取消該 whole trajectory 的資格,但保留已通過的 local evidence 供 sequence DP 使用。
|
| 97 |
+
這個額外 dual-ASR whole gate 只對 local turbo 全數通過的候選執行,DP 選定後仍會保留上述最終
|
| 98 |
+
post-join gate。每個 request 只在 waveform float32 bytes、sample rate、normalized target 與 pinned
|
| 99 |
+
verifier profile 完全相同時重用 full large-v3 evidence;任何 sample 改變都會重新驗證。ASR loader
|
| 100 |
+
異常、OOM、空 transcript 或無效 evidence 都 fail closed,不會降級為 turbo-only。
|
| 101 |
若整個 candidate budget 都沒有 joined-safe whole trajectory,多 chunk request 才會進入 bounded
|
| 102 |
k-best DP。已通過 local hard gate 的 chunk 可直接進入 DP;唯一 rejection reason 是
|
| 103 |
`boundary_speaker_drop`、且 drop 不超過 0.15 的 chunk 也可作為 DP-only 候選。這個局部容忍
|
app.py
CHANGED
|
@@ -40,6 +40,8 @@ from production import (
|
|
| 40 |
from quality_runtime import (
|
| 41 |
BASE_GENERATION_POLICY,
|
| 42 |
SAFE_DURATION_GENERATION_POLICY,
|
|
|
|
|
|
|
| 43 |
WHISPER_MODEL_ID,
|
| 44 |
WHISPER_REVISION,
|
| 45 |
CandidateVerification,
|
|
@@ -50,6 +52,7 @@ from quality_runtime import (
|
|
| 50 |
NoQualifiedCandidateError,
|
| 51 |
RELEASE_SPEAKER_TRIGGER_SECONDS,
|
| 52 |
SEQUENCE_FALLBACK_MAX_LOCAL_BOUNDARY_SPEAKER_DROP,
|
|
|
|
| 53 |
active_voiced_duration_seconds,
|
| 54 |
active_audio_rms_db,
|
| 55 |
candidate_limit_for_chunk_budget,
|
|
@@ -63,6 +66,7 @@ from quality_runtime import (
|
|
| 63 |
resolve_request_seed,
|
| 64 |
run_adaptive_cascade,
|
| 65 |
speaker_evidence_from_audio,
|
|
|
|
| 66 |
transcribe_whisper,
|
| 67 |
trajectory_gate_evidence,
|
| 68 |
verify_trajectory,
|
|
@@ -120,6 +124,13 @@ QUALITY_PREFERRED_MIN_SPEAKER_SIMILARITY = 0.25
|
|
| 120 |
QUALITY_PREFERRED_MAX_BOUNDARY_SPEAKER_DROP = 0.05
|
| 121 |
QUALITY_MAX_SEQUENCE_PATHS = 3
|
| 122 |
SHORT_AUDIO_SPEAKER_GATE_SECONDS = 1.50
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
print(f"[BlueMagpie] downloading model from {REPO_ID}@{MODEL_REVISION} ...")
|
|
@@ -128,6 +139,14 @@ print(f"[BlueMagpie] caching speaker encoder from {ECAPA_REPO_ID}@{ECAPA_REVISIO
|
|
| 128 |
ECAPA_DIR = snapshot_download(ECAPA_REPO_ID, revision=ECAPA_REVISION)
|
| 129 |
print(f"[BlueMagpie] caching quality ASR from {WHISPER_MODEL_ID}@{WHISPER_REVISION} ...")
|
| 130 |
ASR_DIR = snapshot_download(WHISPER_MODEL_ID, revision=WHISPER_REVISION)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
tokenizer = PreTrainedTokenizerFast(tokenizer_file=os.path.join(MODEL_DIR, "tokenizer.json"))
|
| 132 |
print(f"[BlueMagpie] loading model on device={DEVICE} ...")
|
| 133 |
model = BlueMagpieModel.from_local(MODEL_DIR, tokenizer=tokenizer, training=False, device=DEVICE)
|
|
@@ -347,6 +366,8 @@ def _verify_trajectory_audio(
|
|
| 347 |
asr_max_new_tokens: int = 128,
|
| 348 |
*,
|
| 349 |
release_speaker_gate: bool = False,
|
|
|
|
|
|
|
| 350 |
):
|
| 351 |
if len(trajectory) != len(chunks):
|
| 352 |
return verify_trajectory(())
|
|
@@ -357,7 +378,7 @@ def _verify_trajectory_audio(
|
|
| 357 |
prepared = prepare_candidate_audio(
|
| 358 |
audio,
|
| 359 |
SR,
|
| 360 |
-
transcriber=lambda waveform, sample_rate:
|
| 361 |
waveform,
|
| 362 |
sample_rate,
|
| 363 |
max_new_tokens=asr_max_new_tokens,
|
|
@@ -386,9 +407,12 @@ def _verify_trajectory_audio(
|
|
| 386 |
speaker_embedding = None
|
| 387 |
rms_db = None
|
| 388 |
measure_speaker = (
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
| 392 |
)
|
| 393 |
if measure_speaker:
|
| 394 |
try:
|
|
@@ -453,6 +477,7 @@ def _verify_trajectory_audio(
|
|
| 453 |
max_prefix_cer=0.0,
|
| 454 |
max_suffix_cer=0.0,
|
| 455 |
max_extra_tail_units=0,
|
|
|
|
| 456 |
short_audio_seconds=(
|
| 457 |
RELEASE_SPEAKER_TRIGGER_SECONDS
|
| 458 |
if release_speaker_gate
|
|
@@ -468,7 +493,37 @@ def _verify_trajectory_audio(
|
|
| 468 |
if release_speaker_gate
|
| 469 |
else QUALITY_MAX_BOUNDARY_SPEAKER_DROP
|
| 470 |
),
|
| 471 |
-
max_pace_cps=QUALITY_MAX_PACE_CPS,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
)
|
| 473 |
|
| 474 |
|
|
@@ -532,6 +587,7 @@ def _qualify_candidate_trajectory_audio(
|
|
| 532 |
whole_target_text: str,
|
| 533 |
anchor: np.ndarray,
|
| 534 |
playback_speed: float,
|
|
|
|
| 535 |
*,
|
| 536 |
candidate_seed: int,
|
| 537 |
):
|
|
@@ -565,9 +621,30 @@ def _qualify_candidate_trajectory_audio(
|
|
| 565 |
f"seed={candidate_seed} "
|
| 566 |
f"{_verification_metric_log_fields(joined_verification)}"
|
| 567 |
)
|
| 568 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
qualified,
|
| 570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
)
|
| 572 |
|
| 573 |
|
|
@@ -577,6 +654,7 @@ def _verify_sequence_trajectory_audio(
|
|
| 577 |
whole_target_text: str,
|
| 578 |
anchor: np.ndarray,
|
| 579 |
playback_speed: float,
|
|
|
|
| 580 |
):
|
| 581 |
"""Verify one ranked DP path after exact production assembly."""
|
| 582 |
|
|
@@ -585,7 +663,7 @@ def _verify_sequence_trajectory_audio(
|
|
| 585 |
chunks,
|
| 586 |
playback_speed,
|
| 587 |
)
|
| 588 |
-
|
| 589 |
(waveform,),
|
| 590 |
(whole_target_text,),
|
| 591 |
anchor,
|
|
@@ -593,14 +671,33 @@ def _verify_sequence_trajectory_audio(
|
|
| 593 |
QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
|
| 594 |
release_speaker_gate=True,
|
| 595 |
)
|
| 596 |
-
status = "verified" if
|
| 597 |
print(
|
| 598 |
-
f"[BlueMagpie] sequence path {status} "
|
| 599 |
f"rank={sequence_result.sequence_path_rank} "
|
| 600 |
f"chunk_candidates={sequence_result.chunk_candidate_indices} "
|
| 601 |
-
f"{_verification_metric_log_fields(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
)
|
| 603 |
-
return verification
|
| 604 |
|
| 605 |
|
| 606 |
def _synthesize(
|
|
@@ -648,6 +745,7 @@ def _synthesize(
|
|
| 648 |
max_generated_text_units=QUALITY_MAX_GENERATED_TEXT_UNITS,
|
| 649 |
)
|
| 650 |
anchor = _speaker_anchor_array(centroid)
|
|
|
|
| 651 |
try:
|
| 652 |
with _GENERATION_LOCK:
|
| 653 |
cascade = run_adaptive_cascade(
|
|
@@ -667,6 +765,7 @@ def _synthesize(
|
|
| 667 |
text,
|
| 668 |
anchor,
|
| 669 |
speed,
|
|
|
|
| 670 |
candidate_seed=seed,
|
| 671 |
),
|
| 672 |
max_candidates=max_candidates,
|
|
@@ -683,6 +782,7 @@ def _synthesize(
|
|
| 683 |
text,
|
| 684 |
anchor,
|
| 685 |
speed,
|
|
|
|
| 686 |
)
|
| 687 |
),
|
| 688 |
max_sequence_paths=QUALITY_MAX_SEQUENCE_PATHS,
|
|
@@ -730,9 +830,23 @@ def _synthesize(
|
|
| 730 |
release_speaker_gate=True,
|
| 731 |
)
|
| 732 |
final_evidence = trajectory_gate_evidence(final_verification)
|
|
|
|
| 733 |
try:
|
| 734 |
require_verified_final_output(final_verification)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
except FinalOutputRejectedError as error:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
print(
|
| 737 |
format_cascade_evidence_log(
|
| 738 |
cascade.diagnostics,
|
|
@@ -744,9 +858,12 @@ def _synthesize(
|
|
| 744 |
)
|
| 745 |
print(
|
| 746 |
"[BlueMagpie] final output rejected "
|
| 747 |
-
f"{_verification_metric_log_fields(final_verification)}"
|
|
|
|
| 748 |
)
|
| 749 |
raise gr.Error("最終合成結果未通過整段內容、語速與音色驗證,未回傳音訊。") from error
|
|
|
|
|
|
|
| 750 |
print(
|
| 751 |
format_cascade_evidence_log(
|
| 752 |
cascade.diagnostics,
|
|
@@ -758,7 +875,9 @@ def _synthesize(
|
|
| 758 |
)
|
| 759 |
print(
|
| 760 |
"[BlueMagpie] final output verified "
|
| 761 |
-
f"
|
|
|
|
|
|
|
| 762 |
)
|
| 763 |
return SR, waveform
|
| 764 |
|
|
|
|
| 40 |
from quality_runtime import (
|
| 41 |
BASE_GENERATION_POLICY,
|
| 42 |
SAFE_DURATION_GENERATION_POLICY,
|
| 43 |
+
VERIFICATION_WHISPER_MODEL_ID,
|
| 44 |
+
VERIFICATION_WHISPER_REVISION,
|
| 45 |
WHISPER_MODEL_ID,
|
| 46 |
WHISPER_REVISION,
|
| 47 |
CandidateVerification,
|
|
|
|
| 52 |
NoQualifiedCandidateError,
|
| 53 |
RELEASE_SPEAKER_TRIGGER_SECONDS,
|
| 54 |
SEQUENCE_FALLBACK_MAX_LOCAL_BOUNDARY_SPEAKER_DROP,
|
| 55 |
+
WholeWaveformVerificationCache,
|
| 56 |
active_voiced_duration_seconds,
|
| 57 |
active_audio_rms_db,
|
| 58 |
candidate_limit_for_chunk_budget,
|
|
|
|
| 66 |
resolve_request_seed,
|
| 67 |
run_adaptive_cascade,
|
| 68 |
speaker_evidence_from_audio,
|
| 69 |
+
transcribe_verification_whisper,
|
| 70 |
transcribe_whisper,
|
| 71 |
trajectory_gate_evidence,
|
| 72 |
verify_trajectory,
|
|
|
|
| 124 |
QUALITY_PREFERRED_MAX_BOUNDARY_SPEAKER_DROP = 0.05
|
| 125 |
QUALITY_MAX_SEQUENCE_PATHS = 3
|
| 126 |
SHORT_AUDIO_SPEAKER_GATE_SECONDS = 1.50
|
| 127 |
+
VERIFICATION_ASR_PROFILE = (
|
| 128 |
+
f"{VERIFICATION_WHISPER_MODEL_ID}@{VERIFICATION_WHISPER_REVISION}:"
|
| 129 |
+
f"greedy:zh:short{QUALITY_PREFIX_SUFFIX_UNITS}:cer{QUALITY_MAX_CER}:"
|
| 130 |
+
f"prefix{QUALITY_PREFIX_SUFFIX_UNITS}=0:"
|
| 131 |
+
f"suffix{QUALITY_PREFIX_SUFFIX_UNITS}=0:tail=0:"
|
| 132 |
+
f"max_tokens{QUALITY_FINAL_ASR_MAX_NEW_TOKENS}"
|
| 133 |
+
)
|
| 134 |
|
| 135 |
|
| 136 |
print(f"[BlueMagpie] downloading model from {REPO_ID}@{MODEL_REVISION} ...")
|
|
|
|
| 139 |
ECAPA_DIR = snapshot_download(ECAPA_REPO_ID, revision=ECAPA_REVISION)
|
| 140 |
print(f"[BlueMagpie] caching quality ASR from {WHISPER_MODEL_ID}@{WHISPER_REVISION} ...")
|
| 141 |
ASR_DIR = snapshot_download(WHISPER_MODEL_ID, revision=WHISPER_REVISION)
|
| 142 |
+
print(
|
| 143 |
+
"[BlueMagpie] caching independent final ASR from "
|
| 144 |
+
f"{VERIFICATION_WHISPER_MODEL_ID}@{VERIFICATION_WHISPER_REVISION} ..."
|
| 145 |
+
)
|
| 146 |
+
VERIFICATION_ASR_DIR = snapshot_download(
|
| 147 |
+
VERIFICATION_WHISPER_MODEL_ID,
|
| 148 |
+
revision=VERIFICATION_WHISPER_REVISION,
|
| 149 |
+
)
|
| 150 |
tokenizer = PreTrainedTokenizerFast(tokenizer_file=os.path.join(MODEL_DIR, "tokenizer.json"))
|
| 151 |
print(f"[BlueMagpie] loading model on device={DEVICE} ...")
|
| 152 |
model = BlueMagpieModel.from_local(MODEL_DIR, tokenizer=tokenizer, training=False, device=DEVICE)
|
|
|
|
| 366 |
asr_max_new_tokens: int = 128,
|
| 367 |
*,
|
| 368 |
release_speaker_gate: bool = False,
|
| 369 |
+
transcriber=transcribe_whisper,
|
| 370 |
+
semantic_only: bool = False,
|
| 371 |
):
|
| 372 |
if len(trajectory) != len(chunks):
|
| 373 |
return verify_trajectory(())
|
|
|
|
| 378 |
prepared = prepare_candidate_audio(
|
| 379 |
audio,
|
| 380 |
SR,
|
| 381 |
+
transcriber=lambda waveform, sample_rate: transcriber(
|
| 382 |
waveform,
|
| 383 |
sample_rate,
|
| 384 |
max_new_tokens=asr_max_new_tokens,
|
|
|
|
| 407 |
speaker_embedding = None
|
| 408 |
rms_db = None
|
| 409 |
measure_speaker = (
|
| 410 |
+
not semantic_only
|
| 411 |
+
and (
|
| 412 |
+
release_speaker_measurement_required(duration)
|
| 413 |
+
if release_speaker_gate
|
| 414 |
+
else duration >= SHORT_AUDIO_SPEAKER_GATE_SECONDS
|
| 415 |
+
)
|
| 416 |
)
|
| 417 |
if measure_speaker:
|
| 418 |
try:
|
|
|
|
| 477 |
max_prefix_cer=0.0,
|
| 478 |
max_suffix_cer=0.0,
|
| 479 |
max_extra_tail_units=0,
|
| 480 |
+
speaker_gate_enabled=not semantic_only,
|
| 481 |
short_audio_seconds=(
|
| 482 |
RELEASE_SPEAKER_TRIGGER_SECONDS
|
| 483 |
if release_speaker_gate
|
|
|
|
| 493 |
if release_speaker_gate
|
| 494 |
else QUALITY_MAX_BOUNDARY_SPEAKER_DROP
|
| 495 |
),
|
| 496 |
+
max_pace_cps=None if semantic_only else QUALITY_MAX_PACE_CPS,
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _verify_independent_whole_audio(
|
| 501 |
+
waveform: np.ndarray,
|
| 502 |
+
target_text: str,
|
| 503 |
+
anchor: np.ndarray,
|
| 504 |
+
cache: WholeWaveformVerificationCache,
|
| 505 |
+
):
|
| 506 |
+
"""Verify exact assembled audio with the independent full large-v3 ASR."""
|
| 507 |
+
|
| 508 |
+
def verifier(exact_waveform, sample_rate, exact_target):
|
| 509 |
+
if sample_rate != SR:
|
| 510 |
+
raise ValueError("independent verifier sample rate mismatch")
|
| 511 |
+
return _verify_trajectory_audio(
|
| 512 |
+
(exact_waveform,),
|
| 513 |
+
(exact_target,),
|
| 514 |
+
anchor,
|
| 515 |
+
1.0,
|
| 516 |
+
QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
|
| 517 |
+
transcriber=transcribe_verification_whisper,
|
| 518 |
+
semantic_only=True,
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
return cache.verify(
|
| 522 |
+
waveform,
|
| 523 |
+
SR,
|
| 524 |
+
target_text,
|
| 525 |
+
VERIFICATION_ASR_PROFILE,
|
| 526 |
+
verifier,
|
| 527 |
)
|
| 528 |
|
| 529 |
|
|
|
|
| 587 |
whole_target_text: str,
|
| 588 |
anchor: np.ndarray,
|
| 589 |
playback_speed: float,
|
| 590 |
+
independent_cache: WholeWaveformVerificationCache,
|
| 591 |
*,
|
| 592 |
candidate_seed: int,
|
| 593 |
):
|
|
|
|
| 621 |
f"seed={candidate_seed} "
|
| 622 |
f"{_verification_metric_log_fields(joined_verification)}"
|
| 623 |
)
|
| 624 |
+
return CandidateVerification(
|
| 625 |
+
qualified,
|
| 626 |
+
joined_output=trajectory_gate_evidence(joined_verification),
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
independent_verification = _verify_independent_whole_audio(
|
| 630 |
+
waveform,
|
| 631 |
+
whole_target_text,
|
| 632 |
+
anchor,
|
| 633 |
+
independent_cache,
|
| 634 |
+
)
|
| 635 |
+
dual_qualified = qualify_trajectory_with_joined_output(
|
| 636 |
qualified,
|
| 637 |
+
independent_verification,
|
| 638 |
+
)
|
| 639 |
+
if not dual_qualified.passed:
|
| 640 |
+
print(
|
| 641 |
+
"[BlueMagpie] candidate independent whole output rejected "
|
| 642 |
+
f"seed={candidate_seed} "
|
| 643 |
+
f"{_verification_metric_log_fields(independent_verification)}"
|
| 644 |
+
)
|
| 645 |
+
return CandidateVerification(
|
| 646 |
+
dual_qualified,
|
| 647 |
+
joined_output=trajectory_gate_evidence(independent_verification),
|
| 648 |
)
|
| 649 |
|
| 650 |
|
|
|
|
| 654 |
whole_target_text: str,
|
| 655 |
anchor: np.ndarray,
|
| 656 |
playback_speed: float,
|
| 657 |
+
independent_cache: WholeWaveformVerificationCache,
|
| 658 |
):
|
| 659 |
"""Verify one ranked DP path after exact production assembly."""
|
| 660 |
|
|
|
|
| 663 |
chunks,
|
| 664 |
playback_speed,
|
| 665 |
)
|
| 666 |
+
turbo_verification = _verify_trajectory_audio(
|
| 667 |
(waveform,),
|
| 668 |
(whole_target_text,),
|
| 669 |
anchor,
|
|
|
|
| 671 |
QUALITY_FINAL_ASR_MAX_NEW_TOKENS,
|
| 672 |
release_speaker_gate=True,
|
| 673 |
)
|
| 674 |
+
status = "verified" if turbo_verification.passed else "rejected"
|
| 675 |
print(
|
| 676 |
+
f"[BlueMagpie] sequence path turbo {status} "
|
| 677 |
f"rank={sequence_result.sequence_path_rank} "
|
| 678 |
f"chunk_candidates={sequence_result.chunk_candidate_indices} "
|
| 679 |
+
f"{_verification_metric_log_fields(turbo_verification)}"
|
| 680 |
+
)
|
| 681 |
+
if not turbo_verification.passed:
|
| 682 |
+
return turbo_verification
|
| 683 |
+
|
| 684 |
+
independent_verification = _verify_independent_whole_audio(
|
| 685 |
+
waveform,
|
| 686 |
+
whole_target_text,
|
| 687 |
+
anchor,
|
| 688 |
+
independent_cache,
|
| 689 |
+
)
|
| 690 |
+
status = "verified" if independent_verification.passed else "rejected"
|
| 691 |
+
print(
|
| 692 |
+
f"[BlueMagpie] sequence path independent {status} "
|
| 693 |
+
f"rank={sequence_result.sequence_path_rank} "
|
| 694 |
+
f"chunk_candidates={sequence_result.chunk_candidate_indices} "
|
| 695 |
+
f"{_verification_metric_log_fields(independent_verification)}"
|
| 696 |
+
)
|
| 697 |
+
return qualify_trajectory_with_joined_output(
|
| 698 |
+
turbo_verification,
|
| 699 |
+
independent_verification,
|
| 700 |
)
|
|
|
|
| 701 |
|
| 702 |
|
| 703 |
def _synthesize(
|
|
|
|
| 745 |
max_generated_text_units=QUALITY_MAX_GENERATED_TEXT_UNITS,
|
| 746 |
)
|
| 747 |
anchor = _speaker_anchor_array(centroid)
|
| 748 |
+
independent_cache = WholeWaveformVerificationCache()
|
| 749 |
try:
|
| 750 |
with _GENERATION_LOCK:
|
| 751 |
cascade = run_adaptive_cascade(
|
|
|
|
| 765 |
text,
|
| 766 |
anchor,
|
| 767 |
speed,
|
| 768 |
+
independent_cache,
|
| 769 |
candidate_seed=seed,
|
| 770 |
),
|
| 771 |
max_candidates=max_candidates,
|
|
|
|
| 782 |
text,
|
| 783 |
anchor,
|
| 784 |
speed,
|
| 785 |
+
independent_cache,
|
| 786 |
)
|
| 787 |
),
|
| 788 |
max_sequence_paths=QUALITY_MAX_SEQUENCE_PATHS,
|
|
|
|
| 830 |
release_speaker_gate=True,
|
| 831 |
)
|
| 832 |
final_evidence = trajectory_gate_evidence(final_verification)
|
| 833 |
+
independent_final_verification = None
|
| 834 |
try:
|
| 835 |
require_verified_final_output(final_verification)
|
| 836 |
+
independent_final_verification = _verify_independent_whole_audio(
|
| 837 |
+
waveform,
|
| 838 |
+
text,
|
| 839 |
+
anchor,
|
| 840 |
+
independent_cache,
|
| 841 |
+
)
|
| 842 |
+
final_evidence = trajectory_gate_evidence(independent_final_verification)
|
| 843 |
+
require_verified_final_output(independent_final_verification)
|
| 844 |
except FinalOutputRejectedError as error:
|
| 845 |
+
independent_fields = (
|
| 846 |
+
"not_run"
|
| 847 |
+
if independent_final_verification is None
|
| 848 |
+
else _verification_metric_log_fields(independent_final_verification)
|
| 849 |
+
)
|
| 850 |
print(
|
| 851 |
format_cascade_evidence_log(
|
| 852 |
cascade.diagnostics,
|
|
|
|
| 858 |
)
|
| 859 |
print(
|
| 860 |
"[BlueMagpie] final output rejected "
|
| 861 |
+
f"turbo={_verification_metric_log_fields(final_verification)} "
|
| 862 |
+
f"independent={independent_fields}"
|
| 863 |
)
|
| 864 |
raise gr.Error("最終合成結果未通過整段內容、語速與音色驗證,未回傳音訊。") from error
|
| 865 |
+
except (RuntimeError, ValueError) as error:
|
| 866 |
+
raise gr.Error("品質驗證暫時無法完成,未回傳未驗證的語音。") from error
|
| 867 |
print(
|
| 868 |
format_cascade_evidence_log(
|
| 869 |
cascade.diagnostics,
|
|
|
|
| 875 |
)
|
| 876 |
print(
|
| 877 |
"[BlueMagpie] final output verified "
|
| 878 |
+
f"turbo_score={final_verification.score:.6f} "
|
| 879 |
+
f"independent_score={independent_final_verification.score:.6f} "
|
| 880 |
+
f"independent_cache_entries={independent_cache.entry_count}"
|
| 881 |
)
|
| 882 |
return SR, waveform
|
| 883 |
|
quality_runtime.py
CHANGED
|
@@ -7,6 +7,7 @@ load the real models lazily while unit tests remain deterministic and offline.
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import json
|
| 11 |
import math
|
| 12 |
import operator
|
|
@@ -24,6 +25,8 @@ from production import AsrComparison, CandidateSequenceSelection, compare_asr_te
|
|
| 24 |
|
| 25 |
WHISPER_MODEL_ID = "openai/whisper-large-v3-turbo"
|
| 26 |
WHISPER_REVISION = "41f01f3fe87f28c78e2fbf8b568835947dd65ed9"
|
|
|
|
|
|
|
| 27 |
WHISPER_ATTENTION_IMPLEMENTATION = "eager"
|
| 28 |
WHISPER_RETURN_ATTENTION_MASK = True
|
| 29 |
WHISPER_SAMPLE_RATE = 16_000
|
|
@@ -424,13 +427,15 @@ class WhisperRuntime:
|
|
| 424 |
dtype: torch.dtype
|
| 425 |
|
| 426 |
|
| 427 |
-
def
|
|
|
|
|
|
|
| 428 |
*,
|
| 429 |
device: str | torch.device | None = None,
|
| 430 |
processor_factory: Any | None = None,
|
| 431 |
model_factory: Any | None = None,
|
| 432 |
) -> WhisperRuntime:
|
| 433 |
-
"""Load
|
| 434 |
|
| 435 |
Factory injection exists for offline tests. The default imports
|
| 436 |
``transformers`` only when this function is first called.
|
|
@@ -447,12 +452,12 @@ def load_pinned_whisper_runtime(
|
|
| 447 |
)
|
| 448 |
dtype = torch.float16 if selected_device.type == "cuda" else torch.float32
|
| 449 |
processor = processor_factory.from_pretrained(
|
| 450 |
-
|
| 451 |
-
revision=
|
| 452 |
)
|
| 453 |
model = model_factory.from_pretrained(
|
| 454 |
-
|
| 455 |
-
revision=
|
| 456 |
attn_implementation=WHISPER_ATTENTION_IMPLEMENTATION,
|
| 457 |
torch_dtype=dtype,
|
| 458 |
low_cpu_mem_usage=True,
|
|
@@ -468,6 +473,40 @@ def load_pinned_whisper_runtime(
|
|
| 468 |
)
|
| 469 |
|
| 470 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
class LazyWhisperASR:
|
| 472 |
"""Thread-safe one-shot lazy loader with an injectable runtime factory."""
|
| 473 |
|
|
@@ -487,6 +526,9 @@ class LazyWhisperASR:
|
|
| 487 |
|
| 488 |
|
| 489 |
_DEFAULT_WHISPER = LazyWhisperASR()
|
|
|
|
|
|
|
|
|
|
| 490 |
|
| 491 |
|
| 492 |
def _split_whisper_audio(
|
|
@@ -639,6 +681,32 @@ def transcribe_whisper(
|
|
| 639 |
return " ".join(str(text).strip() for text in decoded if str(text).strip())
|
| 640 |
|
| 641 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
@dataclass(frozen=True)
|
| 643 |
class PreparedCandidateAudio:
|
| 644 |
"""Validated candidate waveform and its ASR transcript."""
|
|
@@ -1140,6 +1208,7 @@ def verify_candidate(
|
|
| 1140 |
max_prefix_cer: float = 0.0,
|
| 1141 |
max_suffix_cer: float = 0.0,
|
| 1142 |
max_extra_tail_units: int = 0,
|
|
|
|
| 1143 |
short_audio_seconds: float = 1.5,
|
| 1144 |
min_speaker_similarity: float = 0.10,
|
| 1145 |
max_boundary_speaker_drop: float = 0.03,
|
|
@@ -1162,6 +1231,9 @@ def verify_candidate(
|
|
| 1162 |
short_unit_limit = max(0, int(short_text_units))
|
| 1163 |
except (TypeError, ValueError, OverflowError):
|
| 1164 |
short_unit_limit = -1
|
|
|
|
|
|
|
|
|
|
| 1165 |
|
| 1166 |
# First normalize with a permissive finite limit to determine target units.
|
| 1167 |
preliminary = compare_asr_text(
|
|
@@ -1207,23 +1279,31 @@ def verify_candidate(
|
|
| 1207 |
elif pace > max_pace:
|
| 1208 |
reasons.append("pace_too_fast")
|
| 1209 |
|
| 1210 |
-
valid_common_config =
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1220 |
)
|
| 1221 |
-
)
|
| 1222 |
if not valid_common_config:
|
| 1223 |
reasons.append("invalid_gate_config")
|
| 1224 |
|
| 1225 |
speaker_gate_applied = bool(
|
| 1226 |
-
|
|
|
|
| 1227 |
and short_duration_limit is not None
|
| 1228 |
and duration >= short_duration_limit
|
| 1229 |
)
|
|
@@ -1323,6 +1403,84 @@ def trajectory_gate_evidence(
|
|
| 1323 |
)
|
| 1324 |
|
| 1325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1326 |
def verify_trajectory(
|
| 1327 |
observations: Sequence[CandidateObservation],
|
| 1328 |
*,
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import hashlib
|
| 11 |
import json
|
| 12 |
import math
|
| 13 |
import operator
|
|
|
|
| 25 |
|
| 26 |
WHISPER_MODEL_ID = "openai/whisper-large-v3-turbo"
|
| 27 |
WHISPER_REVISION = "41f01f3fe87f28c78e2fbf8b568835947dd65ed9"
|
| 28 |
+
VERIFICATION_WHISPER_MODEL_ID = "openai/whisper-large-v3"
|
| 29 |
+
VERIFICATION_WHISPER_REVISION = "06f233fe06e710322aca913c1bc4249a0d71fce1"
|
| 30 |
WHISPER_ATTENTION_IMPLEMENTATION = "eager"
|
| 31 |
WHISPER_RETURN_ATTENTION_MASK = True
|
| 32 |
WHISPER_SAMPLE_RATE = 16_000
|
|
|
|
| 427 |
dtype: torch.dtype
|
| 428 |
|
| 429 |
|
| 430 |
+
def _load_whisper_runtime(
|
| 431 |
+
model_id: str,
|
| 432 |
+
revision: str,
|
| 433 |
*,
|
| 434 |
device: str | torch.device | None = None,
|
| 435 |
processor_factory: Any | None = None,
|
| 436 |
model_factory: Any | None = None,
|
| 437 |
) -> WhisperRuntime:
|
| 438 |
+
"""Load one exact ASR revision through the shared deterministic contract.
|
| 439 |
|
| 440 |
Factory injection exists for offline tests. The default imports
|
| 441 |
``transformers`` only when this function is first called.
|
|
|
|
| 452 |
)
|
| 453 |
dtype = torch.float16 if selected_device.type == "cuda" else torch.float32
|
| 454 |
processor = processor_factory.from_pretrained(
|
| 455 |
+
model_id,
|
| 456 |
+
revision=revision,
|
| 457 |
)
|
| 458 |
model = model_factory.from_pretrained(
|
| 459 |
+
model_id,
|
| 460 |
+
revision=revision,
|
| 461 |
attn_implementation=WHISPER_ATTENTION_IMPLEMENTATION,
|
| 462 |
torch_dtype=dtype,
|
| 463 |
low_cpu_mem_usage=True,
|
|
|
|
| 473 |
)
|
| 474 |
|
| 475 |
|
| 476 |
+
def load_pinned_whisper_runtime(
|
| 477 |
+
*,
|
| 478 |
+
device: str | torch.device | None = None,
|
| 479 |
+
processor_factory: Any | None = None,
|
| 480 |
+
model_factory: Any | None = None,
|
| 481 |
+
) -> WhisperRuntime:
|
| 482 |
+
"""Load the pinned turbo ASR used for local candidate screening."""
|
| 483 |
+
|
| 484 |
+
return _load_whisper_runtime(
|
| 485 |
+
WHISPER_MODEL_ID,
|
| 486 |
+
WHISPER_REVISION,
|
| 487 |
+
device=device,
|
| 488 |
+
processor_factory=processor_factory,
|
| 489 |
+
model_factory=model_factory,
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def load_pinned_verification_whisper_runtime(
|
| 494 |
+
*,
|
| 495 |
+
device: str | torch.device | None = None,
|
| 496 |
+
processor_factory: Any | None = None,
|
| 497 |
+
model_factory: Any | None = None,
|
| 498 |
+
) -> WhisperRuntime:
|
| 499 |
+
"""Load the independent full large-v3 ASR used only for whole outputs."""
|
| 500 |
+
|
| 501 |
+
return _load_whisper_runtime(
|
| 502 |
+
VERIFICATION_WHISPER_MODEL_ID,
|
| 503 |
+
VERIFICATION_WHISPER_REVISION,
|
| 504 |
+
device=device,
|
| 505 |
+
processor_factory=processor_factory,
|
| 506 |
+
model_factory=model_factory,
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
class LazyWhisperASR:
|
| 511 |
"""Thread-safe one-shot lazy loader with an injectable runtime factory."""
|
| 512 |
|
|
|
|
| 526 |
|
| 527 |
|
| 528 |
_DEFAULT_WHISPER = LazyWhisperASR()
|
| 529 |
+
_DEFAULT_VERIFICATION_WHISPER = LazyWhisperASR(
|
| 530 |
+
load_pinned_verification_whisper_runtime
|
| 531 |
+
)
|
| 532 |
|
| 533 |
|
| 534 |
def _split_whisper_audio(
|
|
|
|
| 681 |
return " ".join(str(text).strip() for text in decoded if str(text).strip())
|
| 682 |
|
| 683 |
|
| 684 |
+
def transcribe_verification_whisper(
|
| 685 |
+
audio: np.ndarray | Sequence[float],
|
| 686 |
+
sample_rate: int,
|
| 687 |
+
*,
|
| 688 |
+
lazy_asr: LazyWhisperASR | None = None,
|
| 689 |
+
runtime: WhisperRuntime | None = None,
|
| 690 |
+
language: str = "zh",
|
| 691 |
+
task: str = "transcribe",
|
| 692 |
+
max_new_tokens: int = 128,
|
| 693 |
+
) -> str:
|
| 694 |
+
"""Transcribe with the separately pinned full large-v3 final verifier."""
|
| 695 |
+
|
| 696 |
+
selected_lazy = lazy_asr
|
| 697 |
+
if runtime is None and selected_lazy is None:
|
| 698 |
+
selected_lazy = _DEFAULT_VERIFICATION_WHISPER
|
| 699 |
+
return transcribe_whisper(
|
| 700 |
+
audio,
|
| 701 |
+
sample_rate,
|
| 702 |
+
lazy_asr=selected_lazy,
|
| 703 |
+
runtime=runtime,
|
| 704 |
+
language=language,
|
| 705 |
+
task=task,
|
| 706 |
+
max_new_tokens=max_new_tokens,
|
| 707 |
+
)
|
| 708 |
+
|
| 709 |
+
|
| 710 |
@dataclass(frozen=True)
|
| 711 |
class PreparedCandidateAudio:
|
| 712 |
"""Validated candidate waveform and its ASR transcript."""
|
|
|
|
| 1208 |
max_prefix_cer: float = 0.0,
|
| 1209 |
max_suffix_cer: float = 0.0,
|
| 1210 |
max_extra_tail_units: int = 0,
|
| 1211 |
+
speaker_gate_enabled: bool = True,
|
| 1212 |
short_audio_seconds: float = 1.5,
|
| 1213 |
min_speaker_similarity: float = 0.10,
|
| 1214 |
max_boundary_speaker_drop: float = 0.03,
|
|
|
|
| 1231 |
short_unit_limit = max(0, int(short_text_units))
|
| 1232 |
except (TypeError, ValueError, OverflowError):
|
| 1233 |
short_unit_limit = -1
|
| 1234 |
+
speaker_enabled = isinstance(speaker_gate_enabled, (bool, np.bool_))
|
| 1235 |
+
if speaker_enabled:
|
| 1236 |
+
speaker_enabled = bool(speaker_gate_enabled)
|
| 1237 |
|
| 1238 |
# First normalize with a permissive finite limit to determine target units.
|
| 1239 |
preliminary = compare_asr_text(
|
|
|
|
| 1279 |
elif pace > max_pace:
|
| 1280 |
reasons.append("pace_too_fast")
|
| 1281 |
|
| 1282 |
+
valid_common_config = bool(
|
| 1283 |
+
isinstance(speaker_gate_enabled, (bool, np.bool_))
|
| 1284 |
+
and short_duration_limit is not None
|
| 1285 |
+
and general_cer_limit is not None
|
| 1286 |
+
and exact_cer_limit is not None
|
| 1287 |
+
and short_unit_limit >= 0
|
| 1288 |
+
and (
|
| 1289 |
+
not speaker_enabled
|
| 1290 |
+
or all(
|
| 1291 |
+
value is not None
|
| 1292 |
+
for value in (
|
| 1293 |
+
min_similarity,
|
| 1294 |
+
max_boundary,
|
| 1295 |
+
speaker_cost_weight,
|
| 1296 |
+
boundary_cost_weight,
|
| 1297 |
+
)
|
| 1298 |
+
)
|
| 1299 |
)
|
| 1300 |
+
)
|
| 1301 |
if not valid_common_config:
|
| 1302 |
reasons.append("invalid_gate_config")
|
| 1303 |
|
| 1304 |
speaker_gate_applied = bool(
|
| 1305 |
+
speaker_enabled
|
| 1306 |
+
and duration is not None
|
| 1307 |
and short_duration_limit is not None
|
| 1308 |
and duration >= short_duration_limit
|
| 1309 |
)
|
|
|
|
| 1403 |
)
|
| 1404 |
|
| 1405 |
|
| 1406 |
+
def exact_waveform_sha256(
|
| 1407 |
+
audio: np.ndarray | Sequence[float],
|
| 1408 |
+
sample_rate: int,
|
| 1409 |
+
) -> str:
|
| 1410 |
+
"""Hash exact canonical float32 samples together with their sample rate."""
|
| 1411 |
+
|
| 1412 |
+
if isinstance(sample_rate, (bool, np.bool_)):
|
| 1413 |
+
raise ValueError("sample_rate must be a positive integer")
|
| 1414 |
+
try:
|
| 1415 |
+
rate = operator.index(sample_rate)
|
| 1416 |
+
except (TypeError, ValueError, OverflowError) as error:
|
| 1417 |
+
raise ValueError("sample_rate must be a positive integer") from error
|
| 1418 |
+
if rate <= 0:
|
| 1419 |
+
raise ValueError("sample_rate must be a positive integer")
|
| 1420 |
+
waveform = _mono_audio(audio)
|
| 1421 |
+
canonical = np.ascontiguousarray(waveform, dtype=np.dtype("<f4"))
|
| 1422 |
+
digest = hashlib.sha256()
|
| 1423 |
+
digest.update(b"bluemagpie-whole-waveform-f32le-v1\0")
|
| 1424 |
+
digest.update(int(rate).to_bytes(8, "little", signed=False))
|
| 1425 |
+
digest.update(int(canonical.size).to_bytes(8, "little", signed=False))
|
| 1426 |
+
digest.update(memoryview(canonical).cast("B"))
|
| 1427 |
+
return digest.hexdigest()
|
| 1428 |
+
|
| 1429 |
+
|
| 1430 |
+
class WholeWaveformVerificationCache:
|
| 1431 |
+
"""Request-local cache keyed by exact audio, target and verifier profile.
|
| 1432 |
+
|
| 1433 |
+
The cache deliberately accepts no process-global state. A caller must
|
| 1434 |
+
instantiate it inside one synthesis request, and verifier calls that raise
|
| 1435 |
+
are never cached. Passed and rejected gate evidence are both deterministic
|
| 1436 |
+
evidence and may be reused only for an exact key match.
|
| 1437 |
+
"""
|
| 1438 |
+
|
| 1439 |
+
def __init__(self) -> None:
|
| 1440 |
+
self._entries: dict[
|
| 1441 |
+
tuple[str, int, str, str],
|
| 1442 |
+
TrajectoryGateResult,
|
| 1443 |
+
] = {}
|
| 1444 |
+
|
| 1445 |
+
@property
|
| 1446 |
+
def entry_count(self) -> int:
|
| 1447 |
+
return len(self._entries)
|
| 1448 |
+
|
| 1449 |
+
def verify(
|
| 1450 |
+
self,
|
| 1451 |
+
audio: np.ndarray | Sequence[float],
|
| 1452 |
+
sample_rate: int,
|
| 1453 |
+
target_text: str,
|
| 1454 |
+
verifier_profile: str,
|
| 1455 |
+
verifier: Callable[[np.ndarray, int, str], TrajectoryGateResult],
|
| 1456 |
+
) -> TrajectoryGateResult:
|
| 1457 |
+
if not isinstance(target_text, str) or not target_text:
|
| 1458 |
+
raise ValueError("target_text must be a non-empty string")
|
| 1459 |
+
if not isinstance(verifier_profile, str) or not verifier_profile:
|
| 1460 |
+
raise ValueError("verifier_profile must be a non-empty string")
|
| 1461 |
+
if not callable(verifier):
|
| 1462 |
+
raise ValueError("verifier must be callable")
|
| 1463 |
+
waveform = _mono_audio(audio)
|
| 1464 |
+
if isinstance(sample_rate, (bool, np.bool_)):
|
| 1465 |
+
raise ValueError("sample_rate must be a positive integer")
|
| 1466 |
+
try:
|
| 1467 |
+
rate = operator.index(sample_rate)
|
| 1468 |
+
except (TypeError, ValueError, OverflowError) as error:
|
| 1469 |
+
raise ValueError("sample_rate must be a positive integer") from error
|
| 1470 |
+
if rate <= 0:
|
| 1471 |
+
raise ValueError("sample_rate must be a positive integer")
|
| 1472 |
+
waveform_hash = exact_waveform_sha256(waveform, rate)
|
| 1473 |
+
key = (waveform_hash, rate, target_text, verifier_profile)
|
| 1474 |
+
cached = self._entries.get(key)
|
| 1475 |
+
if cached is not None:
|
| 1476 |
+
return cached
|
| 1477 |
+
verification = verifier(waveform, rate, target_text)
|
| 1478 |
+
if not isinstance(verification, TrajectoryGateResult):
|
| 1479 |
+
raise RuntimeError("whole-waveform verifier returned an invalid result")
|
| 1480 |
+
self._entries[key] = verification
|
| 1481 |
+
return verification
|
| 1482 |
+
|
| 1483 |
+
|
| 1484 |
def verify_trajectory(
|
| 1485 |
observations: Sequence[CandidateObservation],
|
| 1486 |
*,
|
tests/test_quality_runtime.py
CHANGED
|
@@ -14,6 +14,8 @@ from quality_runtime import (
|
|
| 14 |
CASCADE_EVIDENCE_LOG_PREFIX,
|
| 15 |
CASCADE_EVIDENCE_SCHEMA_VERSION,
|
| 16 |
SAFE_DURATION_GENERATION_POLICY,
|
|
|
|
|
|
|
| 17 |
WHISPER_ATTENTION_IMPLEMENTATION,
|
| 18 |
WHISPER_MODEL_ID,
|
| 19 |
WHISPER_RETURN_ATTENTION_MASK,
|
|
@@ -28,6 +30,7 @@ from quality_runtime import (
|
|
| 28 |
RELEASE_SPEAKER_TRIGGER_SECONDS,
|
| 29 |
SEQUENCE_FALLBACK_MAX_LOCAL_BOUNDARY_SPEAKER_DROP,
|
| 30 |
TrajectoryGateResult,
|
|
|
|
| 31 |
_sequence_fallback_candidate_result,
|
| 32 |
WhisperRuntime,
|
| 33 |
_split_whisper_audio,
|
|
@@ -35,9 +38,11 @@ from quality_runtime import (
|
|
| 35 |
candidate_chunk_transition_score,
|
| 36 |
candidate_limit_for_chunk_budget,
|
| 37 |
cosine_similarity,
|
|
|
|
| 38 |
format_cascade_evidence_log,
|
| 39 |
generation_policy_for_candidate_offset,
|
| 40 |
load_pinned_whisper_runtime,
|
|
|
|
| 41 |
prepare_candidate_audio,
|
| 42 |
qualify_trajectory_with_joined_output,
|
| 43 |
release_speaker_evidence_from_audio,
|
|
@@ -50,6 +55,7 @@ from quality_runtime import (
|
|
| 50 |
speaker_evidence_from_audio,
|
| 51 |
trim_release_speaker_activity,
|
| 52 |
transcribe_whisper,
|
|
|
|
| 53 |
trajectory_gate_evidence,
|
| 54 |
verify_candidate,
|
| 55 |
verify_trajectory,
|
|
@@ -352,6 +358,34 @@ def test_pinned_whisper_loader_uses_exact_revision_without_global_download():
|
|
| 352 |
assert WHISPER_RETURN_ATTENTION_MASK is True
|
| 353 |
|
| 354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
def test_lazy_whisper_runtime_loads_once_and_transcription_is_deterministic():
|
| 356 |
processor = _FakeProcessor()
|
| 357 |
model = _FakeWhisperModel()
|
|
@@ -384,6 +418,25 @@ def test_lazy_whisper_runtime_loads_once_and_transcription_is_deterministic():
|
|
| 384 |
}
|
| 385 |
|
| 386 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
def test_long_whisper_audio_is_batched_below_thirty_second_limit():
|
| 388 |
processor = _FakeProcessor()
|
| 389 |
model = _FakeWhisperModel()
|
|
@@ -661,6 +714,42 @@ def test_short_candidate_uses_exact_semantics_but_bypasses_speaker_hard_gate():
|
|
| 661 |
assert failed.rejection_reasons == ("semantic_gate",)
|
| 662 |
|
| 663 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
def test_candidate_pace_gate_is_optional_and_fails_closed_when_enabled():
|
| 665 |
base = CandidateObservation(
|
| 666 |
target_text="內容完整",
|
|
@@ -774,6 +863,129 @@ def test_final_whole_output_gate_rejects_post_join_regression_without_fallback()
|
|
| 774 |
require_verified_final_output(None)
|
| 775 |
|
| 776 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
def test_joined_output_rejection_preserves_local_results_for_sequence_dp():
|
| 778 |
observations = []
|
| 779 |
artifacts = []
|
|
@@ -1513,6 +1725,33 @@ def test_adaptive_cascade_expands_to_five_and_selects_lowest_verified_score():
|
|
| 1513 |
assert result.attempted_seeds == (100, 101, 102, 103, 104)
|
| 1514 |
|
| 1515 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1516 |
def test_adaptive_cascade_uses_ten_only_when_stage_five_has_no_verified_candidate():
|
| 1517 |
calls = []
|
| 1518 |
|
|
|
|
| 14 |
CASCADE_EVIDENCE_LOG_PREFIX,
|
| 15 |
CASCADE_EVIDENCE_SCHEMA_VERSION,
|
| 16 |
SAFE_DURATION_GENERATION_POLICY,
|
| 17 |
+
VERIFICATION_WHISPER_MODEL_ID,
|
| 18 |
+
VERIFICATION_WHISPER_REVISION,
|
| 19 |
WHISPER_ATTENTION_IMPLEMENTATION,
|
| 20 |
WHISPER_MODEL_ID,
|
| 21 |
WHISPER_RETURN_ATTENTION_MASK,
|
|
|
|
| 30 |
RELEASE_SPEAKER_TRIGGER_SECONDS,
|
| 31 |
SEQUENCE_FALLBACK_MAX_LOCAL_BOUNDARY_SPEAKER_DROP,
|
| 32 |
TrajectoryGateResult,
|
| 33 |
+
WholeWaveformVerificationCache,
|
| 34 |
_sequence_fallback_candidate_result,
|
| 35 |
WhisperRuntime,
|
| 36 |
_split_whisper_audio,
|
|
|
|
| 38 |
candidate_chunk_transition_score,
|
| 39 |
candidate_limit_for_chunk_budget,
|
| 40 |
cosine_similarity,
|
| 41 |
+
exact_waveform_sha256,
|
| 42 |
format_cascade_evidence_log,
|
| 43 |
generation_policy_for_candidate_offset,
|
| 44 |
load_pinned_whisper_runtime,
|
| 45 |
+
load_pinned_verification_whisper_runtime,
|
| 46 |
prepare_candidate_audio,
|
| 47 |
qualify_trajectory_with_joined_output,
|
| 48 |
release_speaker_evidence_from_audio,
|
|
|
|
| 55 |
speaker_evidence_from_audio,
|
| 56 |
trim_release_speaker_activity,
|
| 57 |
transcribe_whisper,
|
| 58 |
+
transcribe_verification_whisper,
|
| 59 |
trajectory_gate_evidence,
|
| 60 |
verify_candidate,
|
| 61 |
verify_trajectory,
|
|
|
|
| 358 |
assert WHISPER_RETURN_ATTENTION_MASK is True
|
| 359 |
|
| 360 |
|
| 361 |
+
def test_pinned_verification_whisper_loader_is_separate_and_exact():
|
| 362 |
+
processor = _FakeProcessor()
|
| 363 |
+
model = _FakeWhisperModel()
|
| 364 |
+
processor_factory = _Factory(processor)
|
| 365 |
+
model_factory = _Factory(model)
|
| 366 |
+
|
| 367 |
+
runtime = load_pinned_verification_whisper_runtime(
|
| 368 |
+
device="cpu",
|
| 369 |
+
processor_factory=processor_factory,
|
| 370 |
+
model_factory=model_factory,
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
assert runtime.processor is processor
|
| 374 |
+
assert runtime.model is model
|
| 375 |
+
assert processor_factory.calls == [
|
| 376 |
+
(
|
| 377 |
+
(VERIFICATION_WHISPER_MODEL_ID,),
|
| 378 |
+
{"revision": VERIFICATION_WHISPER_REVISION},
|
| 379 |
+
)
|
| 380 |
+
]
|
| 381 |
+
model_args, model_kwargs = model_factory.calls[0]
|
| 382 |
+
assert model_args == (VERIFICATION_WHISPER_MODEL_ID,)
|
| 383 |
+
assert model_kwargs["revision"] == VERIFICATION_WHISPER_REVISION
|
| 384 |
+
assert model_kwargs["attn_implementation"] == WHISPER_ATTENTION_IMPLEMENTATION
|
| 385 |
+
assert model_kwargs["torch_dtype"] == torch.float32
|
| 386 |
+
assert model_kwargs["use_safetensors"] is True
|
| 387 |
+
|
| 388 |
+
|
| 389 |
def test_lazy_whisper_runtime_loads_once_and_transcription_is_deterministic():
|
| 390 |
processor = _FakeProcessor()
|
| 391 |
model = _FakeWhisperModel()
|
|
|
|
| 418 |
}
|
| 419 |
|
| 420 |
|
| 421 |
+
def test_verification_transcriber_uses_injected_full_runtime_without_turbo_state():
|
| 422 |
+
processor = _FakeProcessor()
|
| 423 |
+
model = _FakeWhisperModel()
|
| 424 |
+
runtime = WhisperRuntime(processor, model, torch.device("cpu"), torch.float32)
|
| 425 |
+
loads = []
|
| 426 |
+
lazy_asr = LazyWhisperASR(lambda: loads.append("verification") or runtime)
|
| 427 |
+
|
| 428 |
+
transcript = transcribe_verification_whisper(
|
| 429 |
+
_tone(seconds=0.5),
|
| 430 |
+
16_000,
|
| 431 |
+
lazy_asr=lazy_asr,
|
| 432 |
+
max_new_tokens=440,
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
assert transcript == "合成內容完整。"
|
| 436 |
+
assert loads == ["verification"]
|
| 437 |
+
assert model.generate_calls[0][1]["max_new_tokens"] == 440
|
| 438 |
+
|
| 439 |
+
|
| 440 |
def test_long_whisper_audio_is_batched_below_thirty_second_limit():
|
| 441 |
processor = _FakeProcessor()
|
| 442 |
model = _FakeWhisperModel()
|
|
|
|
| 714 |
assert failed.rejection_reasons == ("semantic_gate",)
|
| 715 |
|
| 716 |
|
| 717 |
+
def test_semantic_only_gate_never_requires_speaker_evidence_but_stays_strict():
|
| 718 |
+
long_target = "這是一段超過短音訊門檻而且內容完整的驗證文字"
|
| 719 |
+
passed = verify_candidate(
|
| 720 |
+
CandidateObservation(
|
| 721 |
+
target_text=long_target,
|
| 722 |
+
transcript_text=long_target,
|
| 723 |
+
audio_duration_seconds=10.0,
|
| 724 |
+
),
|
| 725 |
+
speaker_gate_enabled=False,
|
| 726 |
+
)
|
| 727 |
+
failed = verify_candidate(
|
| 728 |
+
CandidateObservation(
|
| 729 |
+
target_text=long_target,
|
| 730 |
+
transcript_text=long_target[:-2],
|
| 731 |
+
audio_duration_seconds=10.0,
|
| 732 |
+
),
|
| 733 |
+
speaker_gate_enabled=False,
|
| 734 |
+
)
|
| 735 |
+
invalid = verify_candidate(
|
| 736 |
+
CandidateObservation(
|
| 737 |
+
target_text=long_target,
|
| 738 |
+
transcript_text=long_target,
|
| 739 |
+
audio_duration_seconds=10.0,
|
| 740 |
+
),
|
| 741 |
+
speaker_gate_enabled="no",
|
| 742 |
+
)
|
| 743 |
+
|
| 744 |
+
assert passed.passed
|
| 745 |
+
assert not passed.speaker_gate_applied
|
| 746 |
+
assert passed.score == 0.0
|
| 747 |
+
assert not failed.passed
|
| 748 |
+
assert failed.rejection_reasons == ("semantic_gate",)
|
| 749 |
+
assert not invalid.passed
|
| 750 |
+
assert "invalid_gate_config" in invalid.rejection_reasons
|
| 751 |
+
|
| 752 |
+
|
| 753 |
def test_candidate_pace_gate_is_optional_and_fails_closed_when_enabled():
|
| 754 |
base = CandidateObservation(
|
| 755 |
target_text="內容完整",
|
|
|
|
| 863 |
require_verified_final_output(None)
|
| 864 |
|
| 865 |
|
| 866 |
+
def test_whole_waveform_cache_reuses_only_exact_audio_target_rate_and_profile():
|
| 867 |
+
cache = WholeWaveformVerificationCache()
|
| 868 |
+
waveform = _tone(seconds=0.25)
|
| 869 |
+
calls = []
|
| 870 |
+
|
| 871 |
+
def verifier(exact_waveform, sample_rate, target_text):
|
| 872 |
+
calls.append((exact_waveform.copy(), sample_rate, target_text))
|
| 873 |
+
return _joined_verification(target_text, target_text)
|
| 874 |
+
|
| 875 |
+
first = cache.verify(waveform, 16_000, "內容完整", "large-v3@one", verifier)
|
| 876 |
+
second = cache.verify(
|
| 877 |
+
waveform.copy(),
|
| 878 |
+
16_000,
|
| 879 |
+
"內容完整",
|
| 880 |
+
"large-v3@one",
|
| 881 |
+
verifier,
|
| 882 |
+
)
|
| 883 |
+
changed = waveform.copy()
|
| 884 |
+
changed[-1] = np.nextafter(changed[-1], np.float32(1.0))
|
| 885 |
+
cache.verify(changed, 16_000, "內容完整", "large-v3@one", verifier)
|
| 886 |
+
cache.verify(waveform, 48_000, "內容完整", "large-v3@one", verifier)
|
| 887 |
+
cache.verify(waveform, 16_000, "另一內容", "large-v3@one", verifier)
|
| 888 |
+
cache.verify(waveform, 16_000, "內容完整", "large-v3@two", verifier)
|
| 889 |
+
|
| 890 |
+
assert first is second
|
| 891 |
+
assert len(calls) == 5
|
| 892 |
+
assert cache.entry_count == 5
|
| 893 |
+
assert exact_waveform_sha256(waveform, 16_000) == exact_waveform_sha256(
|
| 894 |
+
waveform.copy(),
|
| 895 |
+
16_000,
|
| 896 |
+
)
|
| 897 |
+
assert exact_waveform_sha256(waveform, 16_000) != exact_waveform_sha256(
|
| 898 |
+
waveform,
|
| 899 |
+
48_000,
|
| 900 |
+
)
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
def test_whole_waveform_cache_caches_rejections_but_never_errors_or_bad_results():
|
| 904 |
+
waveform = _tone(seconds=0.25)
|
| 905 |
+
rejected = _joined_verification("完整內容", "錯誤內容")
|
| 906 |
+
cache = WholeWaveformVerificationCache()
|
| 907 |
+
rejection_calls = []
|
| 908 |
+
|
| 909 |
+
def reject_verifier(*args):
|
| 910 |
+
rejection_calls.append(args)
|
| 911 |
+
return rejected
|
| 912 |
+
|
| 913 |
+
assert not cache.verify(
|
| 914 |
+
waveform,
|
| 915 |
+
16_000,
|
| 916 |
+
"完整內容",
|
| 917 |
+
"large-v3@one",
|
| 918 |
+
reject_verifier,
|
| 919 |
+
).passed
|
| 920 |
+
assert not cache.verify(
|
| 921 |
+
waveform.copy(),
|
| 922 |
+
16_000,
|
| 923 |
+
"完整內容",
|
| 924 |
+
"large-v3@one",
|
| 925 |
+
reject_verifier,
|
| 926 |
+
).passed
|
| 927 |
+
assert len(rejection_calls) == 1
|
| 928 |
+
assert cache.entry_count == 1
|
| 929 |
+
|
| 930 |
+
error_cache = WholeWaveformVerificationCache()
|
| 931 |
+
error_calls = []
|
| 932 |
+
|
| 933 |
+
def fail_verifier(*args):
|
| 934 |
+
error_calls.append(args)
|
| 935 |
+
raise RuntimeError("verification OOM")
|
| 936 |
+
|
| 937 |
+
for _ in range(2):
|
| 938 |
+
with pytest.raises(RuntimeError, match="OOM"):
|
| 939 |
+
error_cache.verify(
|
| 940 |
+
waveform,
|
| 941 |
+
16_000,
|
| 942 |
+
"完整內容",
|
| 943 |
+
"large-v3@one",
|
| 944 |
+
fail_verifier,
|
| 945 |
+
)
|
| 946 |
+
assert len(error_calls) == 2
|
| 947 |
+
assert error_cache.entry_count == 0
|
| 948 |
+
|
| 949 |
+
with pytest.raises(RuntimeError, match="invalid result"):
|
| 950 |
+
error_cache.verify(
|
| 951 |
+
waveform,
|
| 952 |
+
16_000,
|
| 953 |
+
"完整內容",
|
| 954 |
+
"large-v3@one",
|
| 955 |
+
lambda *args: True,
|
| 956 |
+
)
|
| 957 |
+
assert error_cache.entry_count == 0
|
| 958 |
+
|
| 959 |
+
|
| 960 |
+
@pytest.mark.parametrize(
|
| 961 |
+
("audio", "sample_rate", "target", "profile", "match"),
|
| 962 |
+
[
|
| 963 |
+
([0.0, math.nan], 16_000, "內容", "profile", "non-finite"),
|
| 964 |
+
([0.0], 0, "內容", "profile", "sample_rate"),
|
| 965 |
+
([0.0], True, "內容", "profile", "sample_rate"),
|
| 966 |
+
([0.0], 16_000, "", "profile", "target_text"),
|
| 967 |
+
([0.0], 16_000, "內容", "", "verifier_profile"),
|
| 968 |
+
],
|
| 969 |
+
)
|
| 970 |
+
def test_whole_waveform_cache_rejects_malformed_keys(
|
| 971 |
+
audio,
|
| 972 |
+
sample_rate,
|
| 973 |
+
target,
|
| 974 |
+
profile,
|
| 975 |
+
match,
|
| 976 |
+
):
|
| 977 |
+
cache = WholeWaveformVerificationCache()
|
| 978 |
+
with pytest.raises(ValueError, match=match):
|
| 979 |
+
cache.verify(
|
| 980 |
+
audio,
|
| 981 |
+
sample_rate,
|
| 982 |
+
target,
|
| 983 |
+
profile,
|
| 984 |
+
lambda *args: _joined_verification("內容", "內容"),
|
| 985 |
+
)
|
| 986 |
+
assert cache.entry_count == 0
|
| 987 |
+
|
| 988 |
+
|
| 989 |
def test_joined_output_rejection_preserves_local_results_for_sequence_dp():
|
| 990 |
observations = []
|
| 991 |
artifacts = []
|
|
|
|
| 1725 |
assert result.attempted_seeds == (100, 101, 102, 103, 104)
|
| 1726 |
|
| 1727 |
|
| 1728 |
+
def test_hard_secondary_rejection_continues_cascade_to_later_candidate():
|
| 1729 |
+
calls = []
|
| 1730 |
+
|
| 1731 |
+
def generator(chunks, seed):
|
| 1732 |
+
calls.append(seed)
|
| 1733 |
+
return (chunks[0], seed)
|
| 1734 |
+
|
| 1735 |
+
def dual_verifier(trajectory, chunks, seed):
|
| 1736 |
+
# This models a turbo-passing candidate rejected by the independent
|
| 1737 |
+
# whole-output ASR. It remains a normal rejected gate result so the
|
| 1738 |
+
# deterministic cascade must continue instead of returning it.
|
| 1739 |
+
return _gate_result(seed == 101, score=0.0)
|
| 1740 |
+
|
| 1741 |
+
result = run_adaptive_cascade(
|
| 1742 |
+
["完整內容"],
|
| 1743 |
+
100,
|
| 1744 |
+
generator,
|
| 1745 |
+
dual_verifier,
|
| 1746 |
+
max_candidates=5,
|
| 1747 |
+
)
|
| 1748 |
+
|
| 1749 |
+
assert calls == [100, 101, 102, 103, 104]
|
| 1750 |
+
assert result.seed == 101
|
| 1751 |
+
assert result.candidate_index == 1
|
| 1752 |
+
assert result.attempted_seeds == (100, 101, 102, 103, 104)
|
| 1753 |
+
|
| 1754 |
+
|
| 1755 |
def test_adaptive_cascade_uses_ten_only_when_stage_five_has_no_verified_candidate():
|
| 1756 |
calls = []
|
| 1757 |
|
tests/test_release_pins.py
CHANGED
|
@@ -72,9 +72,18 @@ def test_quality_asr_is_revision_pinned():
|
|
| 72 |
|
| 73 |
assert constants["WHISPER_MODEL_ID"] == "openai/whisper-large-v3-turbo"
|
| 74 |
assert constants["WHISPER_REVISION"] == "41f01f3fe87f28c78e2fbf8b568835947dd65ed9"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
assert constants["WHISPER_ATTENTION_IMPLEMENTATION"] == "eager"
|
| 76 |
assert "WHISPER_RETURN_ATTENTION_MASK = True" in quality_source
|
| 77 |
assert "snapshot_download(WHISPER_MODEL_ID, revision=WHISPER_REVISION)" in app_source
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
def test_quality_runtime_dependencies_are_version_pinned():
|
|
@@ -264,6 +273,103 @@ def test_whole_candidate_qualification_uses_the_exact_return_assembler_after_loc
|
|
| 264 |
assert "require_verified_final_output(final_verification)" in synthesize_source
|
| 265 |
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
def test_space_wires_bounded_k_best_paths_to_exact_assembled_whole_gate():
|
| 268 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 269 |
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
|
|
| 72 |
|
| 73 |
assert constants["WHISPER_MODEL_ID"] == "openai/whisper-large-v3-turbo"
|
| 74 |
assert constants["WHISPER_REVISION"] == "41f01f3fe87f28c78e2fbf8b568835947dd65ed9"
|
| 75 |
+
assert constants["VERIFICATION_WHISPER_MODEL_ID"] == "openai/whisper-large-v3"
|
| 76 |
+
assert (
|
| 77 |
+
constants["VERIFICATION_WHISPER_REVISION"]
|
| 78 |
+
== "06f233fe06e710322aca913c1bc4249a0d71fce1"
|
| 79 |
+
)
|
| 80 |
assert constants["WHISPER_ATTENTION_IMPLEMENTATION"] == "eager"
|
| 81 |
assert "WHISPER_RETURN_ATTENTION_MASK = True" in quality_source
|
| 82 |
assert "snapshot_download(WHISPER_MODEL_ID, revision=WHISPER_REVISION)" in app_source
|
| 83 |
+
assert "snapshot_download(\n VERIFICATION_WHISPER_MODEL_ID," in app_source
|
| 84 |
+
assert "revision=VERIFICATION_WHISPER_REVISION" in app_source
|
| 85 |
+
assert "load_pinned_verification_whisper_runtime" in quality_source
|
| 86 |
+
assert "transcribe_verification_whisper" in quality_source
|
| 87 |
|
| 88 |
|
| 89 |
def test_quality_runtime_dependencies_are_version_pinned():
|
|
|
|
| 273 |
assert "require_verified_final_output(final_verification)" in synthesize_source
|
| 274 |
|
| 275 |
|
| 276 |
+
def test_space_hard_intersects_dual_asr_only_on_exact_whole_waveforms():
|
| 277 |
+
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 278 |
+
tree = ast.parse(source)
|
| 279 |
+
functions = {
|
| 280 |
+
node.name: node
|
| 281 |
+
for node in tree.body
|
| 282 |
+
if isinstance(node, ast.FunctionDef)
|
| 283 |
+
}
|
| 284 |
+
verify_source = ast.get_source_segment(source, functions["_verify_trajectory_audio"])
|
| 285 |
+
independent_source = ast.get_source_segment(
|
| 286 |
+
source,
|
| 287 |
+
functions["_verify_independent_whole_audio"],
|
| 288 |
+
)
|
| 289 |
+
qualify_source = ast.get_source_segment(
|
| 290 |
+
source,
|
| 291 |
+
functions["_qualify_candidate_trajectory_audio"],
|
| 292 |
+
)
|
| 293 |
+
sequence_source = ast.get_source_segment(
|
| 294 |
+
source,
|
| 295 |
+
functions["_verify_sequence_trajectory_audio"],
|
| 296 |
+
)
|
| 297 |
+
synthesize_source = ast.get_source_segment(source, functions["_synthesize"])
|
| 298 |
+
|
| 299 |
+
assert all(
|
| 300 |
+
segment is not None
|
| 301 |
+
for segment in (
|
| 302 |
+
verify_source,
|
| 303 |
+
independent_source,
|
| 304 |
+
qualify_source,
|
| 305 |
+
sequence_source,
|
| 306 |
+
synthesize_source,
|
| 307 |
+
)
|
| 308 |
+
)
|
| 309 |
+
assert "transcriber=transcribe_whisper" in verify_source
|
| 310 |
+
assert "speaker_gate_enabled=not semantic_only" in verify_source
|
| 311 |
+
assert "transcriber=transcribe_verification_whisper" in independent_source
|
| 312 |
+
assert "semantic_only=True" in independent_source
|
| 313 |
+
assert "cache.verify(" in independent_source
|
| 314 |
+
assert "VERIFICATION_ASR_PROFILE" in independent_source
|
| 315 |
+
|
| 316 |
+
local_index = qualify_source.index("local_verification = _verify_trajectory_audio(")
|
| 317 |
+
local_fail_index = qualify_source.index("if not local_verification.passed:")
|
| 318 |
+
assemble_index = qualify_source.index("waveform = _assemble_trajectory_audio(")
|
| 319 |
+
turbo_joined_index = qualify_source.index("joined_verification = _verify_trajectory_audio(")
|
| 320 |
+
turbo_fail_index = qualify_source.index("if not qualified.passed:")
|
| 321 |
+
independent_index = qualify_source.index("_verify_independent_whole_audio(")
|
| 322 |
+
dual_index = qualify_source.index(
|
| 323 |
+
"dual_qualified = qualify_trajectory_with_joined_output("
|
| 324 |
+
)
|
| 325 |
+
assert (
|
| 326 |
+
local_index
|
| 327 |
+
< local_fail_index
|
| 328 |
+
< assemble_index
|
| 329 |
+
< turbo_joined_index
|
| 330 |
+
< turbo_fail_index
|
| 331 |
+
< independent_index
|
| 332 |
+
< dual_index
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
sequence_assemble = sequence_source.index("_assemble_trajectory_audio(")
|
| 336 |
+
sequence_turbo = sequence_source.index("turbo_verification = _verify_trajectory_audio(")
|
| 337 |
+
sequence_turbo_fail = sequence_source.index("if not turbo_verification.passed:")
|
| 338 |
+
sequence_independent = sequence_source.index("_verify_independent_whole_audio(")
|
| 339 |
+
assert sequence_assemble < sequence_turbo < sequence_turbo_fail < sequence_independent
|
| 340 |
+
|
| 341 |
+
cache_create = synthesize_source.index(
|
| 342 |
+
"independent_cache = WholeWaveformVerificationCache()"
|
| 343 |
+
)
|
| 344 |
+
cascade_index = synthesize_source.index("cascade = run_adaptive_cascade(")
|
| 345 |
+
final_assemble = synthesize_source.index(
|
| 346 |
+
"waveform = _assemble_trajectory_audio(cascade.trajectory, chunks, speed)"
|
| 347 |
+
)
|
| 348 |
+
final_turbo = synthesize_source.index("final_verification = _verify_trajectory_audio(")
|
| 349 |
+
final_turbo_require = synthesize_source.index(
|
| 350 |
+
"require_verified_final_output(final_verification)"
|
| 351 |
+
)
|
| 352 |
+
final_independent = synthesize_source.index(
|
| 353 |
+
"independent_final_verification = _verify_independent_whole_audio("
|
| 354 |
+
)
|
| 355 |
+
final_independent_require = synthesize_source.index(
|
| 356 |
+
"require_verified_final_output(independent_final_verification)"
|
| 357 |
+
)
|
| 358 |
+
return_index = synthesize_source.index("return SR, waveform")
|
| 359 |
+
assert (
|
| 360 |
+
cache_create
|
| 361 |
+
< cascade_index
|
| 362 |
+
< final_assemble
|
| 363 |
+
< final_turbo
|
| 364 |
+
< final_turbo_require
|
| 365 |
+
< final_independent
|
| 366 |
+
< final_independent_require
|
| 367 |
+
< return_index
|
| 368 |
+
)
|
| 369 |
+
assert synthesize_source.count("independent_cache,") >= 3
|
| 370 |
+
assert "except (RuntimeError, ValueError) as error:" in synthesize_source
|
| 371 |
+
|
| 372 |
+
|
| 373 |
def test_space_wires_bounded_k_best_paths_to_exact_assembled_whole_gate():
|
| 374 |
source = (ROOT / "app.py").read_text(encoding="utf-8")
|
| 375 |
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|