voidful commited on
Commit
e99cd17
·
1 Parent(s): fe62dd7

Add dual-ASR whole-output verification

Browse files
Files changed (5) hide show
  1. README.md +12 -5
  2. app.py +133 -14
  3. quality_runtime.py +176 -18
  4. tests/test_quality_runtime.py +239 -0
  5. 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 | whole / first 6 / last 6 units, no lexical tail; fail closed |
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
- 執行一次 normalized target 的 prefix/whole/suffix/tail、pace 與 speaker anchor/boundary gate;這個
89
- post-join gate 失敗時不會回傳先前已通過的 chunk 音訊。
 
 
90
  此外,same-seed trajectory 只有在每個 local chunk 都通過後,才會先用同一個
91
  `_assemble_trajectory_audio` 組成實際播放版本並跑整段 semantic/pace/speaker gate;joined gate
92
  失敗會取消該 whole trajectory 的資格,但保留已通過的 local evidence 供 sequence DP 使用。
93
- 這個額外 whole-ASR 只對 local 全數通過的候選執行,DP 選定後仍會保留上述最終 post-join gate。
 
 
 
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: transcribe_whisper(
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
- release_speaker_measurement_required(duration)
390
- if release_speaker_gate
391
- else duration >= SHORT_AUDIO_SPEAKER_GATE_SECONDS
 
 
 
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
- return CandidateVerification(
 
 
 
 
 
 
 
 
 
 
 
569
  qualified,
570
- joined_output=trajectory_gate_evidence(joined_verification),
 
 
 
 
 
 
 
 
 
 
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
- verification = _verify_trajectory_audio(
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 verification.passed else "rejected"
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(verification)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"score={final_verification.score:.6f}"
 
 
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 load_pinned_whisper_runtime(
 
 
428
  *,
429
  device: str | torch.device | None = None,
430
  processor_factory: Any | None = None,
431
  model_factory: Any | None = None,
432
  ) -> WhisperRuntime:
433
- """Load the exact ASR revision used by the quality gate.
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
- WHISPER_MODEL_ID,
451
- revision=WHISPER_REVISION,
452
  )
453
  model = model_factory.from_pretrained(
454
- WHISPER_MODEL_ID,
455
- revision=WHISPER_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 = all(
1211
- value is not None
1212
- for value in (
1213
- short_duration_limit,
1214
- general_cer_limit,
1215
- exact_cer_limit,
1216
- min_similarity,
1217
- max_boundary,
1218
- speaker_cost_weight,
1219
- boundary_cost_weight,
 
 
 
 
 
 
 
1220
  )
1221
- ) and short_unit_limit >= 0
1222
  if not valid_common_config:
1223
  reasons.append("invalid_gate_config")
1224
 
1225
  speaker_gate_applied = bool(
1226
- duration is not None
 
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")