voidful commited on
Commit
086aff2
·
verified ·
1 Parent(s): da756c9

Replace phase vocoder with guarded WSOLA

Browse files
README.md CHANGED
@@ -293,7 +293,7 @@ independent full-large-v3 與 final whole-waveform gate 全部維持 exact endpo
293
  不超過 6 個 speech units 的短句會使用最低 CFG 3.0;stop threshold 只在預估進度 75% 後逐步降低,並於 95% 進度降至 0.05,以捕捉句末弱 stop 訊號而不影響前段內容。
294
  一至兩個 speech units 的極短普通文字若原本會以 1.0 倍速輸出,會從未經處理的 model waveform
295
  直接做一次保音高的 0.95 倍重繪,再接受完整 semantic、SQUIM、endpoint 與 PCM16 gate;
296
- 不會串接多次 phase-vocoder,也不會放寬任何候選門檻。
297
  80 字內的 request 不再為了 onset 額外切段;長文才依自然標點與 80 字上限切段,避免人工
298
  endpoint、孤立引號與不必要的局部 prefix/suffix gate。Release
299
  profile 鎖定 primary CFG 3.0 / alternate CFG 2.0 交錯候選排程、NFE 10、
@@ -304,12 +304,18 @@ pause 不會稀釋 CPS。逐 active interval 的 stretcher 曾在固定 root abl
304
  回退,因此仍不切開 active intervals;production 會先在 model 的 raw completed waveform 上合併
305
  total-duration 與 active-union 的 bounded rate,再量測一次 probe waveform。若 probe active pace
306
  仍高於 3.95,會把 residual rate 乘回原 combined rate,並從 untouched model waveform 重新 render;
307
- 最後交給 verifier 與使用者的 samples 因此只經過一層 librosa phase vocoder一般文字
308
- 固定 `n_fft=1536`、`hop_length=384`URL/email code-switch chunk 使用 `2048/512` 保留較細的
309
- network lexical distinction stretch rate 低於 0.80這替最高 1.05 playback speed 與
310
- chunk join 保留 4.3 CPS gate 安全餘量;任何仍過快或被 stretch 破壞的候選照常由後續完整 gate
311
- fail closed。Median-F0 只對實際具備 DP coverage eligibility 的 multi-chunk rows 計算,single/K32、
312
- semantic reject、joined 與 final waveform 不再支付無用的 pYIN latency。
 
 
 
 
 
 
313
 
314
  Generation work 以實際產生的 chunks 與其 speech units 累計:初始 trajectory 花費
315
  `chunk count` 與全文 speech units;每次 refill 再加 1 chunk 與該段 units。任一 request 最多
 
293
  不超過 6 個 speech units 的短句會使用最低 CFG 3.0;stop threshold 只在預估進度 75% 後逐步降低,並於 95% 進度降至 0.05,以捕捉句末弱 stop 訊號而不影響前段內容。
294
  一至兩個 speech units 的極短普通文字若原本會以 1.0 倍速輸出,會從未經處理的 model waveform
295
  直接做一次保音高的 0.95 倍重繪,再接受完整 semantic、SQUIM、endpoint 與 PCM16 gate;
296
+ 不會串接多次 time-scale modification,也不會放寬任何候選門檻。
297
  80 字內的 request 不再為了 onset 額外切段;長文才依自然標點與 80 字上限切段,避免人工
298
  endpoint、孤立引號與不必要的局部 prefix/suffix gate。Release
299
  profile 鎖定 primary CFG 3.0 / alternate CFG 2.0 交錯候選排程、NFE 10、
 
304
  回退,因此仍不切開 active intervals;production 會先在 model 的 raw completed waveform 上合併
305
  total-duration 與 active-union 的 bounded rate,再量測一次 probe waveform。若 probe active pace
306
  仍高於 3.95,會把 residual rate 乘回原 combined rate,並從 untouched model waveform 重新 render;
307
+ 最後交給 verifier 與使用者的 samples 因此只經過一層 deterministic mono WSOLA自動 stretch
308
+ rate 的硬下限固定 0.90過去 long-text `0.76` escape 已移除;若 0.90 仍無法通過 4.3 CPS
309
+ gate必須改選候選或 fail closed,得再拉長公開的後處理語速固定為 1.0,避免第二次
310
+ time-scale modification 與隱性rate 相乘。
311
+
312
+ 所有不少於 1.0 秒的 local、joined 與 final waveform 另通過 deterministic no-reference
313
+ echo/smearing gate。Gate 聚合多個 active windows 的 24–180 ms 長 quefrency cepstrum,拒絕固定
314
+ delayed-copy comb peak 或 dense smearing signature;無效、非有限或缺少足夠 active-window
315
+ evidence 都 fail closed。短於 1.0 秒時 estimator 不適用,仍由原有 strict semantic、endpoint
316
+ 與 SQUIM gate 負責。任何仍過快、被 stretch 破壞或具有可量測回音的候選均不會回傳。
317
+ Median-F0 只對實際具備 DP coverage eligibility 的 multi-chunk rows 計算,single/K32、semantic
318
+ reject、joined 與 final waveform 不再支付無用的 pYIN latency。
319
 
320
  Generation work 以實際產生的 chunks 與其 speech units 累計:初始 trajectory 花費
321
  `chunk count` 與全文 speech units;每次 refill 再加 1 chunk 與該段 units。任一 request 最多
app.py CHANGED
@@ -92,6 +92,11 @@ torch.backends.cudnn.deterministic = CUDNN_DETERMINISTIC
92
  torch.backends.cuda.matmul.allow_tf32 = CUDA_MATMUL_ALLOW_TF32
93
  torch.backends.cudnn.allow_tf32 = CUDNN_ALLOW_TF32
94
 
 
 
 
 
 
95
  from bluemagpie import BlueMagpieModel
96
  from glyph_ascii_v1 import GLYPH_ASCII_V1_SYMBOLS
97
  from glyph_assets import (
@@ -244,15 +249,11 @@ STOP_LATE_THRESHOLD = 0.05
244
  STOP_LATE_START_RATIO = 0.75
245
  STOP_LATE_FULL_RATIO = 0.95
246
  STOP_CONSECUTIVE = 1
247
- MIN_PACE_SPEED = 0.80
248
  EXTREME_SHORT_MAX_UNITS = 2
249
  EXTREME_SHORT_MAX_SPEED = 0.95
250
- PACE_ONLY_FALLBACK_MIN_SPEED = 0.76
251
  PACE_ONLY_FALLBACK_MIN_UNITS = 30
252
- PACE_STRETCH_N_FFT = 1536
253
- PACE_STRETCH_HOP_LENGTH = 384
254
- NETWORK_PACE_STRETCH_N_FFT = 2048
255
- NETWORK_PACE_STRETCH_HOP_LENGTH = 512
256
  MAX_TEXT_CHARS = 360
257
  QUALITY_MAX_GENERATED_CHUNKS = 32
258
  QUALITY_MAX_GENERATED_TEXT_UNITS = 800
@@ -530,24 +531,112 @@ def _apply_speed(
530
  *,
531
  network_conditioned: bool = False,
532
  ) -> np.ndarray:
533
- speed = float(speed or 1.0)
 
 
 
 
 
 
 
 
 
 
534
  if abs(speed - 1.0) < 1.0e-3:
535
- return audio
536
- n_fft = (
537
- NETWORK_PACE_STRETCH_N_FFT
538
- if network_conditioned
539
- else PACE_STRETCH_N_FFT
540
  )
541
- hop_length = (
542
- NETWORK_PACE_STRETCH_HOP_LENGTH
543
- if network_conditioned
544
- else PACE_STRETCH_HOP_LENGTH
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  )
546
- return librosa.effects.time_stretch(
547
- np.asarray(audio, dtype=np.float32),
548
- rate=speed,
549
- n_fft=n_fft,
550
- hop_length=hop_length,
 
 
 
 
 
 
 
 
 
 
 
551
  )
552
 
553
 
@@ -737,7 +826,7 @@ def _generate_chunk(
737
  f"total_rate={final_speed:.6f}"
738
  )
739
  # Re-render from the untouched model waveform so the returned samples
740
- # have exactly one pitch-preserving phase-vocoder pass.
741
  final_audio = _apply_speed(
742
  audio,
743
  final_speed,
@@ -1122,6 +1211,11 @@ def _verify_trajectory_audio(
1122
  min_squim_stoi=QUALITY_MIN_SQUIM_STOI,
1123
  min_squim_pesq=QUALITY_MIN_SQUIM_PESQ,
1124
  )
 
 
 
 
 
1125
  return _attach_transition_f0(
1126
  verification,
1127
  tuple(prepared_waveforms),
@@ -2035,8 +2129,8 @@ def _synthesize(
2035
  raise gr.Error("輸入必須包含可發音的文字或數字。")
2036
  if normalized_units > QUALITY_MAX_GENERATED_TEXT_UNITS:
2037
  raise gr.Error("正規化後的語音單位超過安全生成上限,請分段合成。")
2038
- if not np.isfinite(float(speed)) or not 0.85 <= float(speed) <= 1.05:
2039
- raise gr.Error("後處理語速必須介於 0.85 與 1.05。")
2040
 
2041
  chunk_specs: tuple[GenerationChunkSpec, ...] | None = None
2042
  try:
@@ -3377,7 +3471,8 @@ HEADER = f"""
3377
  不額外切開 80 字內的首段,長文才依自然標點與 80 字上限切段。每個 request 只生成一次
3378
  same-seed 完整 trajectory;失敗後只補低覆蓋 chunks,再以 speaker/RMS/F0 ragged DP 選出最多三條
3379
  culprit-diverse exact paths。NFE 固定為已驗證的 10,且每個 request 最多生成 32 個 TTS chunks
3380
- 與 800 speech units。
 
3381
  """
3382
 
3383
 
@@ -3397,7 +3492,11 @@ with gr.Blocks(title="BlueMagpie-TTS Demo", theme=gr.themes.Soft()) as demo:
3397
  interactive=False,
3398
  label="NFE steps(已驗證固定值)",
3399
  )
3400
- speed_input = gr.Slider(0.85, 1.05, value=1.0, step=0.05, label="後處理語速")
 
 
 
 
3401
 
3402
  with gr.Tab("內建語者"):
3403
  with gr.Row():
 
92
  torch.backends.cuda.matmul.allow_tf32 = CUDA_MATMUL_ALLOW_TF32
93
  torch.backends.cudnn.allow_tf32 = CUDNN_ALLOW_TF32
94
 
95
+ from audio_wsola import wsola_time_stretch
96
+ from audio_artifact_gate import (
97
+ DEFAULT_ECHO_SMEARING_GATE_CONFIG,
98
+ evaluate_echo_smearing,
99
+ )
100
  from bluemagpie import BlueMagpieModel
101
  from glyph_ascii_v1 import GLYPH_ASCII_V1_SYMBOLS
102
  from glyph_assets import (
 
249
  STOP_LATE_START_RATIO = 0.75
250
  STOP_LATE_FULL_RATIO = 0.95
251
  STOP_CONSECUTIVE = 1
252
+ MIN_PACE_SPEED = 0.90
253
  EXTREME_SHORT_MAX_UNITS = 2
254
  EXTREME_SHORT_MAX_SPEED = 0.95
255
+ PACE_ONLY_FALLBACK_MIN_SPEED = MIN_PACE_SPEED
256
  PACE_ONLY_FALLBACK_MIN_UNITS = 30
 
 
 
 
257
  MAX_TEXT_CHARS = 360
258
  QUALITY_MAX_GENERATED_CHUNKS = 32
259
  QUALITY_MAX_GENERATED_TEXT_UNITS = 800
 
531
  *,
532
  network_conditioned: bool = False,
533
  ) -> np.ndarray:
534
+ del network_conditioned
535
+ try:
536
+ speed = float(speed)
537
+ except (TypeError, ValueError, OverflowError) as error:
538
+ raise ValueError("speed must be finite and positive") from error
539
+ if not np.isfinite(speed) or speed <= 0.0:
540
+ raise ValueError("speed must be finite and positive")
541
+ if speed < MIN_PACE_SPEED:
542
+ raise ValueError(
543
+ f"speed is below the production WSOLA floor {MIN_PACE_SPEED:.2f}"
544
+ )
545
  if abs(speed - 1.0) < 1.0e-3:
546
+ return np.asarray(audio, dtype=np.float32)
547
+ return wsola_time_stretch(
548
+ audio,
549
+ rate=speed,
550
+ sample_rate=SR,
551
  )
552
+
553
+
554
+ def _apply_echo_smearing_gate(
555
+ verification,
556
+ waveforms: tuple[np.ndarray | None, ...],
557
+ ):
558
+ """Reject analyzable candidates with fixed echo or smearing evidence.
559
+
560
+ The no-reference metric needs at least one second of waveform evidence.
561
+ Shorter utterances remain governed by the existing semantic, endpoint and
562
+ SQUIM contracts instead of being rejected by an inapplicable estimator.
563
+ Every analyzable waveform is fail-closed: missing/non-finite/insufficient
564
+ active-window evidence becomes an acoustic rejection.
565
+ """
566
+
567
+ if len(waveforms) != len(verification.candidate_results):
568
+ raise ValueError("audio artifact evidence does not align")
569
+ updated_results = []
570
+ for index, (waveform, result) in enumerate(
571
+ zip(waveforms, verification.candidate_results, strict=True)
572
+ ):
573
+ if waveform is None:
574
+ updated_results.append(result)
575
+ continue
576
+ duration_seconds = waveform.size / float(SR)
577
+ if (
578
+ duration_seconds
579
+ < DEFAULT_ECHO_SMEARING_GATE_CONFIG.min_duration_seconds
580
+ ):
581
+ print(
582
+ "[BlueMagpie] audio artifact gate "
583
+ f"chunk={index} applicable=false "
584
+ f"duration={duration_seconds:.6f}"
585
+ )
586
+ updated_results.append(result)
587
+ continue
588
+
589
+ diagnostics = evaluate_echo_smearing(waveform, SR)
590
+ print(
591
+ "[BlueMagpie] audio artifact gate "
592
+ f"chunk={index} applicable=true passed={diagnostics.passed} "
593
+ f"delayed_score={diagnostics.delayed_copy_score:.6f} "
594
+ f"smearing_score={diagnostics.smearing_score:.6f} "
595
+ f"delay_ms={diagnostics.dominant_delay_ms:.3f} "
596
+ f"peak_ratio={diagnostics.cepstral_peak_ratio:.6f} "
597
+ f"windows={diagnostics.analysis_window_count} "
598
+ f"reasons={diagnostics.rejection_reasons}"
599
+ )
600
+ if diagnostics.passed:
601
+ updated_results.append(result)
602
+ continue
603
+ artifact_reasons = tuple(
604
+ f"audio_artifact_{reason}"
605
+ for reason in diagnostics.rejection_reasons
606
+ ) or ("audio_artifact_unavailable",)
607
+ updated_results.append(
608
+ replace(
609
+ result,
610
+ passed=False,
611
+ score=float("inf"),
612
+ rejection_reasons=tuple(
613
+ dict.fromkeys(
614
+ (*result.rejection_reasons, *artifact_reasons)
615
+ )
616
+ ),
617
+ )
618
+ )
619
+
620
+ candidate_results = tuple(updated_results)
621
+ passed = bool(candidate_results) and all(
622
+ result.passed for result in candidate_results
623
  )
624
+ score = (
625
+ sum(result.score for result in candidate_results)
626
+ if passed
627
+ else float("inf")
628
+ )
629
+ rejection_reasons = tuple(
630
+ f"chunk_{index}:{reason}"
631
+ for index, result in enumerate(candidate_results)
632
+ for reason in result.rejection_reasons
633
+ )
634
+ return replace(
635
+ verification,
636
+ passed=passed,
637
+ candidate_results=candidate_results,
638
+ score=score,
639
+ rejection_reasons=rejection_reasons,
640
  )
641
 
642
 
 
826
  f"total_rate={final_speed:.6f}"
827
  )
828
  # Re-render from the untouched model waveform so the returned samples
829
+ # have exactly one pitch-preserving WSOLA pass.
830
  final_audio = _apply_speed(
831
  audio,
832
  final_speed,
 
1211
  min_squim_stoi=QUALITY_MIN_SQUIM_STOI,
1212
  min_squim_pesq=QUALITY_MIN_SQUIM_PESQ,
1213
  )
1214
+ if not semantic_only:
1215
+ verification = _apply_echo_smearing_gate(
1216
+ verification,
1217
+ tuple(prepared_waveforms),
1218
+ )
1219
  return _attach_transition_f0(
1220
  verification,
1221
  tuple(prepared_waveforms),
 
2129
  raise gr.Error("輸入必須包含可發音的文字或數字。")
2130
  if normalized_units > QUALITY_MAX_GENERATED_TEXT_UNITS:
2131
  raise gr.Error("正規化後的語音單位超過安全生成上限,請分段合成。")
2132
+ if not np.isfinite(float(speed)) or float(speed) != 1.0:
2133
+ raise gr.Error("後處理語速固定為 1.0;減速已由品質管線控制。")
2134
 
2135
  chunk_specs: tuple[GenerationChunkSpec, ...] | None = None
2136
  try:
 
3471
  不額外切開 80 字內的首段,長文才依自然標點與 80 字上限切段。每個 request 只生成一次
3472
  same-seed 完整 trajectory;失敗後只補低覆蓋 chunks,再以 speaker/RMS/F0 ragged DP 選出最多三條
3473
  culprit-diverse exact paths。NFE 固定為已驗證的 10,且每個 request 最多生成 32 個 TTS chunks
3474
+ 與 800 speech units。語速校正使用 deterministic WSOLA,總 rate 不低於 0.90;不少於 1 秒的
3475
+ 候選與最終波形另須通過 echo/smearing gate。
3476
  """
3477
 
3478
 
 
3492
  interactive=False,
3493
  label="NFE steps(已驗證固定值)",
3494
  )
3495
+ speed_input = gr.Number(
3496
+ value=1.0,
3497
+ interactive=False,
3498
+ label="後處理語速(已驗證固定值)",
3499
+ )
3500
 
3501
  with gr.Tab("內建語者"):
3502
  with gr.Row():
audio_artifact_gate.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, no-reference echo and smearing gate.
2
+
3
+ The gate is intentionally conservative. It looks for a *time-invariant*
4
+ comb/echo signature that survives across several active analysis windows:
5
+
6
+ * a narrow long-quefrency cepstral peak is evidence of a delayed copy; and
7
+ * a dense set of long-quefrency peaks is a proxy for reverberant smearing.
8
+
9
+ Content-dependent pitch and formants move between windows, while a fixed echo
10
+ path does not. Taking the median cepstrum across windows therefore avoids most
11
+ of the false positives produced by a plain waveform autocorrelation. This is
12
+ not a room-acoustics measurement and should be used as a fail-closed candidate
13
+ gate, not as a perceptual quality score.
14
+
15
+ Only NumPy is required and the analysis is deterministic for identical input.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ import operator
22
+ from dataclasses import dataclass
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class EchoSmearingGateConfig:
30
+ """Configuration for :func:`evaluate_echo_smearing`.
31
+
32
+ The defaults reject only pronounced delayed-copy or dense-comb artifacts.
33
+ Scores are normalized to ``[0, 1]`` before the two gate thresholds are
34
+ applied.
35
+ """
36
+
37
+ min_sample_rate: int = 8_000
38
+ max_sample_rate: int = 192_000
39
+ min_duration_seconds: float = 1.0
40
+ window_seconds: float = 0.60
41
+ candidate_window_count: int = 27
42
+ max_analysis_windows: int = 9
43
+ min_analysis_windows: int = 3
44
+ min_rms: float = 1.0e-5
45
+ relative_active_rms: float = 0.12
46
+ min_delay_ms: float = 24.0
47
+ max_delay_ms: float = 180.0
48
+ frequency_smoothing_hz: float = 70.0
49
+ # A raw median-cepstral peak ratio of 8.965 maps to the default rejection
50
+ # boundary. Calibration controls topped out at 6.61, while the deployed
51
+ # echo-like smoke output measured 10.34.
52
+ delayed_ratio_floor: float = 5.5
53
+ delayed_ratio_ceiling: float = 16.0
54
+ smear_q99_ratio_floor: float = 3.0
55
+ smear_q99_ratio_ceiling: float = 6.0
56
+ smear_density_floor: float = 0.008
57
+ smear_density_ceiling: float = 0.035
58
+ delayed_copy_threshold: float = 0.33
59
+ smearing_threshold: float = 0.40
60
+
61
+
62
+ DEFAULT_ECHO_SMEARING_GATE_CONFIG = EchoSmearingGateConfig()
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class EchoSmearingDiagnostics:
67
+ """Finite diagnostics and the fail-closed gate decision."""
68
+
69
+ passed: bool
70
+ rejection_reasons: tuple[str, ...]
71
+ delayed_copy_score: float
72
+ smearing_score: float
73
+ dominant_delay_ms: float
74
+ cepstral_peak_ratio: float
75
+ cepstral_q99_ratio: float
76
+ cepstral_dense_fraction: float
77
+ duration_seconds: float
78
+ input_rms: float
79
+ analysis_window_count: int
80
+
81
+
82
+ def _bounded(value: float, lower: float, upper: float) -> float:
83
+ if not math.isfinite(value) or upper <= lower:
84
+ return 0.0
85
+ return float(np.clip((value - lower) / (upper - lower), 0.0, 1.0))
86
+
87
+
88
+ def _finite_nonnegative(value: Any) -> float:
89
+ try:
90
+ converted = float(value)
91
+ except (TypeError, ValueError, OverflowError):
92
+ return 0.0
93
+ if not math.isfinite(converted) or converted < 0.0:
94
+ return 0.0
95
+ return converted
96
+
97
+
98
+ def _rejected(
99
+ reason: str,
100
+ *,
101
+ duration_seconds: float = 0.0,
102
+ input_rms: float = 0.0,
103
+ ) -> EchoSmearingDiagnostics:
104
+ return EchoSmearingDiagnostics(
105
+ passed=False,
106
+ rejection_reasons=(reason,),
107
+ delayed_copy_score=0.0,
108
+ smearing_score=0.0,
109
+ dominant_delay_ms=0.0,
110
+ cepstral_peak_ratio=0.0,
111
+ cepstral_q99_ratio=0.0,
112
+ cepstral_dense_fraction=0.0,
113
+ duration_seconds=_finite_nonnegative(duration_seconds),
114
+ input_rms=_finite_nonnegative(input_rms),
115
+ analysis_window_count=0,
116
+ )
117
+
118
+
119
+ def _valid_config(config: EchoSmearingGateConfig) -> bool:
120
+ integer_fields = (
121
+ config.min_sample_rate,
122
+ config.max_sample_rate,
123
+ config.candidate_window_count,
124
+ config.max_analysis_windows,
125
+ config.min_analysis_windows,
126
+ )
127
+ if any(
128
+ isinstance(value, (bool, np.bool_))
129
+ or not isinstance(value, (int, np.integer))
130
+ for value in integer_fields
131
+ ):
132
+ return False
133
+ if not (
134
+ 1 <= config.min_sample_rate <= config.max_sample_rate
135
+ and config.candidate_window_count >= config.max_analysis_windows
136
+ and config.max_analysis_windows >= config.min_analysis_windows >= 1
137
+ ):
138
+ return False
139
+
140
+ finite_fields = (
141
+ config.min_duration_seconds,
142
+ config.window_seconds,
143
+ config.min_rms,
144
+ config.relative_active_rms,
145
+ config.min_delay_ms,
146
+ config.max_delay_ms,
147
+ config.frequency_smoothing_hz,
148
+ config.delayed_ratio_floor,
149
+ config.delayed_ratio_ceiling,
150
+ config.smear_q99_ratio_floor,
151
+ config.smear_q99_ratio_ceiling,
152
+ config.smear_density_floor,
153
+ config.smear_density_ceiling,
154
+ config.delayed_copy_threshold,
155
+ config.smearing_threshold,
156
+ )
157
+ try:
158
+ finite = all(math.isfinite(float(value)) for value in finite_fields)
159
+ except (TypeError, ValueError, OverflowError):
160
+ return False
161
+ if not finite:
162
+ return False
163
+ return bool(
164
+ config.min_duration_seconds > 0.0
165
+ and config.window_seconds > 0.0
166
+ and config.min_rms > 0.0
167
+ and 0.0 < config.relative_active_rms <= 1.0
168
+ and 0.0 < config.min_delay_ms < config.max_delay_ms
169
+ and config.frequency_smoothing_hz > 0.0
170
+ and config.delayed_ratio_floor < config.delayed_ratio_ceiling
171
+ and config.smear_q99_ratio_floor < config.smear_q99_ratio_ceiling
172
+ and config.smear_density_floor < config.smear_density_ceiling
173
+ and 0.0 <= config.delayed_copy_threshold <= 1.0
174
+ and 0.0 <= config.smearing_threshold <= 1.0
175
+ )
176
+
177
+
178
+ def _moving_average(values: np.ndarray, width: int) -> np.ndarray:
179
+ """Return an edge-padded centered moving average in linear time."""
180
+
181
+ width = max(1, min(int(width), int(values.size)))
182
+ if width % 2 == 0:
183
+ width = max(1, width - 1)
184
+ if width == 1:
185
+ return values.copy()
186
+ radius = width // 2
187
+ padded = np.pad(values, (radius, radius), mode="edge")
188
+ cumulative = np.concatenate(
189
+ (np.zeros(1, dtype=np.float64), np.cumsum(padded, dtype=np.float64))
190
+ )
191
+ return (cumulative[width:] - cumulative[:-width]) / float(width)
192
+
193
+
194
+ def _analysis_starts(
195
+ waveform: np.ndarray,
196
+ window_samples: int,
197
+ config: EchoSmearingGateConfig,
198
+ ) -> tuple[np.ndarray, np.ndarray]:
199
+ """Select deterministic high-energy windows distributed over the input."""
200
+
201
+ last_start = waveform.size - window_samples
202
+ candidate_count = min(
203
+ config.candidate_window_count,
204
+ max(1, last_start // max(1, window_samples // 3) + 1),
205
+ )
206
+ starts = np.unique(
207
+ np.linspace(0, last_start, num=candidate_count, dtype=np.int64)
208
+ )
209
+ rms_values_list: list[float] = []
210
+ for start in starts:
211
+ window = np.asarray(
212
+ waveform[int(start) : int(start) + window_samples],
213
+ dtype=np.float64,
214
+ )
215
+ centered = window - float(np.mean(window))
216
+ rms_values_list.append(
217
+ math.sqrt(
218
+ float(
219
+ np.mean(
220
+ np.square(centered, dtype=np.float64),
221
+ dtype=np.float64,
222
+ )
223
+ )
224
+ )
225
+ )
226
+ rms_values = np.asarray(rms_values_list, dtype=np.float64)
227
+ active_floor = max(config.min_rms, config.relative_active_rms * float(rms_values.max()))
228
+ active_indices = np.flatnonzero(rms_values >= active_floor)
229
+ if active_indices.size > config.max_analysis_windows:
230
+ # Keep the strongest windows. Sorting their positions afterwards
231
+ # makes the output independent of NumPy's tie ordering.
232
+ ranked = sorted(
233
+ active_indices.tolist(),
234
+ key=lambda index: (-float(rms_values[index]), int(starts[index])),
235
+ )
236
+ active_indices = np.asarray(
237
+ sorted(ranked[: config.max_analysis_windows]), dtype=np.int64
238
+ )
239
+ return starts[active_indices], rms_values[active_indices]
240
+
241
+
242
+ def _window_cepstrum(
243
+ window: np.ndarray,
244
+ sample_rate: int,
245
+ config: EchoSmearingGateConfig,
246
+ max_delay_samples: int,
247
+ ) -> np.ndarray:
248
+ centered = np.asarray(window, dtype=np.float64) - float(np.mean(window))
249
+ emphasized = np.empty_like(centered)
250
+ emphasized[0] = centered[0]
251
+ emphasized[1:] = centered[1:] - 0.97 * centered[:-1]
252
+ emphasized *= np.hanning(emphasized.size)
253
+
254
+ fft_size = 1 << max(1, (2 * emphasized.size - 1).bit_length())
255
+ magnitude = np.abs(np.fft.rfft(emphasized, n=fft_size))
256
+ magnitude_floor = max(
257
+ np.finfo(np.float64).tiny,
258
+ float(magnitude.max()) * 1.0e-6,
259
+ )
260
+ log_magnitude = np.log(np.maximum(magnitude, magnitude_floor))
261
+ bin_hz = sample_rate / float(fft_size)
262
+ smoothing_bins = max(3, int(round(config.frequency_smoothing_hz / bin_hz)))
263
+ if smoothing_bins % 2 == 0:
264
+ smoothing_bins += 1
265
+ residual = log_magnitude - _moving_average(log_magnitude, smoothing_bins)
266
+ residual -= float(np.mean(residual))
267
+ cepstrum = np.abs(np.fft.irfft(residual, n=fft_size))
268
+ return np.asarray(cepstrum[: max_delay_samples + 1], dtype=np.float64)
269
+
270
+
271
+ def evaluate_echo_smearing(
272
+ audio: Any,
273
+ sample_rate: Any,
274
+ *,
275
+ config: EchoSmearingGateConfig = DEFAULT_ECHO_SMEARING_GATE_CONFIG,
276
+ ) -> EchoSmearingDiagnostics:
277
+ """Measure delayed-copy and smearing proxies and fail closed.
278
+
279
+ Invalid, silent, clipped-to-nonfinite, too-short, or analytically
280
+ insufficient inputs return a rejected result rather than raising. Every
281
+ floating-point field in the result is finite.
282
+ """
283
+
284
+ if not isinstance(config, EchoSmearingGateConfig) or not _valid_config(config):
285
+ return _rejected("invalid_config")
286
+ if isinstance(sample_rate, (bool, np.bool_)):
287
+ return _rejected("invalid_sample_rate")
288
+ try:
289
+ rate = operator.index(sample_rate)
290
+ except (TypeError, ValueError, OverflowError):
291
+ return _rejected("invalid_sample_rate")
292
+ rate = int(rate)
293
+ if not config.min_sample_rate <= rate <= config.max_sample_rate:
294
+ return _rejected("invalid_sample_rate")
295
+
296
+ try:
297
+ waveform = np.asarray(audio)
298
+ except (TypeError, ValueError, OverflowError):
299
+ return _rejected("invalid_audio")
300
+ if (
301
+ waveform.ndim != 1
302
+ or waveform.size == 0
303
+ or waveform.dtype.kind not in "fiu"
304
+ ):
305
+ return _rejected("invalid_audio")
306
+ try:
307
+ waveform = waveform.astype(np.float64, copy=False)
308
+ except (TypeError, ValueError, OverflowError):
309
+ return _rejected("invalid_audio")
310
+ duration = float(waveform.size / rate)
311
+ if not np.isfinite(waveform).all():
312
+ return _rejected("nonfinite_audio", duration_seconds=duration)
313
+ input_rms = math.sqrt(
314
+ float(np.mean(np.square(waveform, dtype=np.float64), dtype=np.float64))
315
+ )
316
+ if not math.isfinite(input_rms) or input_rms < config.min_rms:
317
+ return _rejected(
318
+ "silent_audio",
319
+ duration_seconds=duration,
320
+ input_rms=input_rms,
321
+ )
322
+ if duration < config.min_duration_seconds:
323
+ return _rejected(
324
+ "insufficient_duration",
325
+ duration_seconds=duration,
326
+ input_rms=input_rms,
327
+ )
328
+
329
+ window_samples = max(8, int(round(config.window_seconds * rate)))
330
+ min_delay_samples = max(1, int(round(config.min_delay_ms * rate / 1000.0)))
331
+ max_delay_samples = int(round(config.max_delay_ms * rate / 1000.0))
332
+ if (
333
+ waveform.size < window_samples
334
+ or max_delay_samples <= min_delay_samples
335
+ or max_delay_samples >= window_samples // 2
336
+ ):
337
+ return _rejected(
338
+ "invalid_analysis_geometry",
339
+ duration_seconds=duration,
340
+ input_rms=input_rms,
341
+ )
342
+
343
+ starts, _window_rms = _analysis_starts(waveform, window_samples, config)
344
+ if starts.size < config.min_analysis_windows:
345
+ return _rejected(
346
+ "insufficient_active_windows",
347
+ duration_seconds=duration,
348
+ input_rms=input_rms,
349
+ )
350
+
351
+ cepstra = np.asarray(
352
+ [
353
+ _window_cepstrum(
354
+ waveform[int(start) : int(start) + window_samples],
355
+ rate,
356
+ config,
357
+ max_delay_samples,
358
+ )
359
+ for start in starts
360
+ ],
361
+ dtype=np.float64,
362
+ )
363
+ aggregate = np.median(
364
+ cepstra[:, min_delay_samples : max_delay_samples + 1],
365
+ axis=0,
366
+ )
367
+ if aggregate.size == 0 or not np.isfinite(aggregate).all():
368
+ return _rejected(
369
+ "nonfinite_analysis",
370
+ duration_seconds=duration,
371
+ input_rms=input_rms,
372
+ )
373
+
374
+ baseline = max(float(np.median(aggregate)), np.finfo(np.float64).eps)
375
+ peak_index = int(np.argmax(aggregate))
376
+ peak_ratio = float(aggregate[peak_index] / baseline)
377
+ q99_ratio = float(np.percentile(aggregate, 99.0) / baseline)
378
+ dense_fraction = float(np.mean(aggregate > (3.0 * baseline)))
379
+ dominant_delay_ms = float(
380
+ (min_delay_samples + peak_index) * 1000.0 / rate
381
+ )
382
+ if not all(
383
+ math.isfinite(value)
384
+ for value in (
385
+ peak_ratio,
386
+ q99_ratio,
387
+ dense_fraction,
388
+ dominant_delay_ms,
389
+ )
390
+ ):
391
+ return _rejected(
392
+ "nonfinite_analysis",
393
+ duration_seconds=duration,
394
+ input_rms=input_rms,
395
+ )
396
+
397
+ delayed_score = _bounded(
398
+ peak_ratio,
399
+ config.delayed_ratio_floor,
400
+ config.delayed_ratio_ceiling,
401
+ )
402
+ q99_component = _bounded(
403
+ q99_ratio,
404
+ config.smear_q99_ratio_floor,
405
+ config.smear_q99_ratio_ceiling,
406
+ )
407
+ density_component = _bounded(
408
+ dense_fraction,
409
+ config.smear_density_floor,
410
+ config.smear_density_ceiling,
411
+ )
412
+ smearing_score = float(math.sqrt(q99_component * density_component))
413
+
414
+ reasons: list[str] = []
415
+ if delayed_score >= config.delayed_copy_threshold:
416
+ reasons.append("delayed_copy")
417
+ if smearing_score >= config.smearing_threshold:
418
+ reasons.append("smearing")
419
+ return EchoSmearingDiagnostics(
420
+ passed=not reasons,
421
+ rejection_reasons=tuple(reasons),
422
+ delayed_copy_score=_finite_nonnegative(delayed_score),
423
+ smearing_score=_finite_nonnegative(smearing_score),
424
+ dominant_delay_ms=_finite_nonnegative(dominant_delay_ms),
425
+ cepstral_peak_ratio=_finite_nonnegative(peak_ratio),
426
+ cepstral_q99_ratio=_finite_nonnegative(q99_ratio),
427
+ cepstral_dense_fraction=_finite_nonnegative(dense_fraction),
428
+ duration_seconds=_finite_nonnegative(duration),
429
+ input_rms=_finite_nonnegative(input_rms),
430
+ analysis_window_count=int(starts.size),
431
+ )
432
+
433
+
434
+ __all__ = [
435
+ "DEFAULT_ECHO_SMEARING_GATE_CONFIG",
436
+ "EchoSmearingDiagnostics",
437
+ "EchoSmearingGateConfig",
438
+ "evaluate_echo_smearing",
439
+ ]
audio_wsola.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic mono WSOLA time-scale modification for production speech.
2
+
3
+ The implementation deliberately has no optional native dependency. It keeps
4
+ pitch by moving and overlap-adding waveform frames in the time domain, while a
5
+ bounded similarity search aligns each new frame to the already synthesized
6
+ waveform. The public ``rate`` follows the librosa convention: values below
7
+ one make audio longer and values above one make it shorter.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import operator
14
+ from typing import Sequence
15
+
16
+ import numpy as np
17
+
18
+
19
+ WSOLA_MIN_RATE = 0.50
20
+ WSOLA_MAX_RATE = 2.00
21
+ WSOLA_FRAME_MS = 40.0
22
+ WSOLA_SYNTHESIS_HOP_MS = 10.0
23
+ WSOLA_SEARCH_MS = 6.0
24
+ WSOLA_MIN_SAMPLES = 256
25
+ WSOLA_CORRELATION_FLOOR = 0.05
26
+
27
+
28
+ def _positive_sample_count(value: object, name: str) -> int:
29
+ if isinstance(value, (bool, np.bool_)):
30
+ raise ValueError(f"{name} must be a positive integer")
31
+ try:
32
+ parsed = operator.index(value)
33
+ except (TypeError, ValueError, OverflowError) as error:
34
+ raise ValueError(f"{name} must be a positive integer") from error
35
+ if parsed <= 0:
36
+ raise ValueError(f"{name} must be a positive integer")
37
+ return int(parsed)
38
+
39
+
40
+ def _finite_positive(value: object, name: str) -> float:
41
+ try:
42
+ parsed = float(value)
43
+ except (TypeError, ValueError, OverflowError) as error:
44
+ raise ValueError(f"{name} must be finite and positive") from error
45
+ if not math.isfinite(parsed) or parsed <= 0.0:
46
+ raise ValueError(f"{name} must be finite and positive")
47
+ return parsed
48
+
49
+
50
+ def _mono_waveform(audio: np.ndarray | Sequence[float]) -> np.ndarray:
51
+ waveform = np.asarray(audio)
52
+ if waveform.ndim == 2 and 1 in waveform.shape:
53
+ waveform = waveform.reshape(-1)
54
+ if waveform.ndim != 1:
55
+ raise ValueError("audio must be mono")
56
+ waveform = np.ascontiguousarray(waveform, dtype=np.float32)
57
+ if waveform.size < WSOLA_MIN_SAMPLES:
58
+ raise ValueError("audio is too short for WSOLA")
59
+ if not np.isfinite(waveform).all():
60
+ raise ValueError("audio must contain only finite samples")
61
+ return waveform
62
+
63
+
64
+ def _frame_positions(target_length: int, frame_length: int, hop: int) -> tuple[int, ...]:
65
+ final_start = target_length - frame_length
66
+ if final_start <= 0:
67
+ return (0,)
68
+ positions = list(range(0, final_start + 1, hop))
69
+ if positions[-1] != final_start:
70
+ positions.append(final_start)
71
+ return tuple(positions)
72
+
73
+
74
+ def _best_source_position(
75
+ signal: np.ndarray,
76
+ synthesized: np.ndarray,
77
+ weight: np.ndarray,
78
+ *,
79
+ output_start: int,
80
+ current_end: int,
81
+ predicted_start: int,
82
+ previous_start: int,
83
+ predicted_step: int,
84
+ frame_length: int,
85
+ search_radius: int,
86
+ ) -> int:
87
+ """Return a monotonic, waveform-similar source position."""
88
+
89
+ source_limit = signal.size - frame_length
90
+ lower = max(
91
+ 0,
92
+ predicted_start - search_radius,
93
+ previous_start + max(1, int(round(0.35 * predicted_step))),
94
+ )
95
+ upper = min(source_limit, predicted_start + search_radius)
96
+ if lower > upper:
97
+ return int(np.clip(predicted_start, 0, source_limit))
98
+
99
+ overlap = min(frame_length, max(0, current_end - output_start))
100
+ if overlap < 32 or lower == upper:
101
+ return int(np.clip(predicted_start, lower, upper))
102
+
103
+ overlap_weights = weight[output_start : output_start + overlap]
104
+ usable = overlap_weights > 1.0e-4
105
+ if int(np.count_nonzero(usable)) < 32:
106
+ return int(np.clip(predicted_start, lower, upper))
107
+ first = int(np.flatnonzero(usable)[0])
108
+ last = int(np.flatnonzero(usable)[-1]) + 1
109
+ if last - first < 32:
110
+ return int(np.clip(predicted_start, lower, upper))
111
+
112
+ reference = (
113
+ synthesized[output_start + first : output_start + last]
114
+ / np.maximum(
115
+ weight[output_start + first : output_start + last],
116
+ np.float32(1.0e-7),
117
+ )
118
+ )
119
+ # Pre-emphasis makes the search follow speech fine structure rather than
120
+ # matching only the slowly changing loudness envelope.
121
+ reference = np.diff(reference, prepend=reference[0]).astype(
122
+ np.float64,
123
+ copy=False,
124
+ )
125
+ reference -= float(np.mean(reference))
126
+ reference_norm = float(np.linalg.norm(reference))
127
+ if not math.isfinite(reference_norm) or reference_norm <= 1.0e-7:
128
+ return int(np.clip(predicted_start, lower, upper))
129
+
130
+ segment_length = last - first
131
+ search = signal[
132
+ lower + first : upper + first + segment_length
133
+ ].astype(np.float64, copy=False)
134
+ search = np.diff(search, prepend=search[0])
135
+ candidate_count = upper - lower + 1
136
+ if search.size < segment_length + candidate_count - 1:
137
+ return int(np.clip(predicted_start, lower, upper))
138
+
139
+ dots = np.correlate(search, reference, mode="valid")
140
+ if dots.size != candidate_count:
141
+ return int(np.clip(predicted_start, lower, upper))
142
+ squared = np.square(search, dtype=np.float64)
143
+ cumulative = np.concatenate(
144
+ (np.zeros(1, dtype=np.float64), np.cumsum(squared, dtype=np.float64))
145
+ )
146
+ norms = np.sqrt(
147
+ np.maximum(
148
+ cumulative[segment_length:] - cumulative[:-segment_length],
149
+ 0.0,
150
+ )
151
+ )
152
+ scores = dots / np.maximum(norms * reference_norm, 1.0e-12)
153
+ finite = np.isfinite(scores)
154
+ if not finite.any():
155
+ return int(np.clip(predicted_start, lower, upper))
156
+ scores = np.where(finite, scores, -np.inf)
157
+ best_offset = int(np.argmax(scores))
158
+ if float(scores[best_offset]) < WSOLA_CORRELATION_FLOOR:
159
+ return int(np.clip(predicted_start, lower, upper))
160
+ return lower + best_offset
161
+
162
+
163
+ def wsola_time_stretch(
164
+ audio: np.ndarray | Sequence[float],
165
+ *,
166
+ rate: float,
167
+ sample_rate: int,
168
+ frame_ms: float = WSOLA_FRAME_MS,
169
+ synthesis_hop_ms: float = WSOLA_SYNTHESIS_HOP_MS,
170
+ search_ms: float = WSOLA_SEARCH_MS,
171
+ ) -> np.ndarray:
172
+ """Return a deterministic, pitch-preserving WSOLA time stretch.
173
+
174
+ The output length is exactly ``round(input_samples / rate)``. Invalid,
175
+ non-finite, multi-channel or pathologically short inputs raise instead of
176
+ falling back to a lower-quality transform.
177
+ """
178
+
179
+ waveform = _mono_waveform(audio)
180
+ source_rate = _positive_sample_count(sample_rate, "sample_rate")
181
+ stretch_rate = _finite_positive(rate, "rate")
182
+ if not WSOLA_MIN_RATE <= stretch_rate <= WSOLA_MAX_RATE:
183
+ raise ValueError(
184
+ f"rate must be between {WSOLA_MIN_RATE:.2f} and {WSOLA_MAX_RATE:.2f}"
185
+ )
186
+ frame_duration = _finite_positive(frame_ms, "frame_ms")
187
+ hop_duration = _finite_positive(synthesis_hop_ms, "synthesis_hop_ms")
188
+ search_duration = _finite_positive(search_ms, "search_ms")
189
+ if abs(stretch_rate - 1.0) < 1.0e-3:
190
+ return waveform.copy()
191
+
192
+ target_length = max(1, int(round(waveform.size / stretch_rate)))
193
+ minimum_extent = min(waveform.size, target_length)
194
+ if minimum_extent < WSOLA_MIN_SAMPLES:
195
+ raise ValueError("stretched audio is too short for WSOLA")
196
+
197
+ default_frame = max(
198
+ 64,
199
+ int(round(frame_duration * source_rate / 1000.0)),
200
+ )
201
+ # Keep at least two synthesis frames even for unusually short utterances.
202
+ frame_length = min(default_frame, max(128, minimum_extent // 2))
203
+ frame_length = min(frame_length, minimum_extent)
204
+ default_hop = max(
205
+ 16,
206
+ int(round(hop_duration * source_rate / 1000.0)),
207
+ )
208
+ synthesis_hop = min(default_hop, max(16, frame_length // 3))
209
+ if synthesis_hop >= frame_length:
210
+ synthesis_hop = max(1, frame_length // 4)
211
+ search_radius = max(
212
+ 1,
213
+ int(round(search_duration * source_rate / 1000.0)),
214
+ )
215
+
216
+ output_positions = _frame_positions(
217
+ target_length,
218
+ frame_length,
219
+ synthesis_hop,
220
+ )
221
+ source_limit = waveform.size - frame_length
222
+ output_limit = target_length - frame_length
223
+ window = np.hanning(frame_length + 2)[1:-1].astype(np.float32)
224
+ synthesized = np.zeros(target_length, dtype=np.float32)
225
+ weight = np.zeros(target_length, dtype=np.float32)
226
+
227
+ previous_source = -1
228
+ previous_predicted = 0
229
+ current_end = 0
230
+ for frame_index, output_start in enumerate(output_positions):
231
+ if frame_index == 0:
232
+ predicted_source = 0
233
+ source_start = 0
234
+ elif frame_index + 1 == len(output_positions):
235
+ predicted_source = source_limit
236
+ source_start = source_limit
237
+ else:
238
+ predicted_source = int(
239
+ round(output_start * source_limit / max(1, output_limit))
240
+ )
241
+ predicted_step = max(1, predicted_source - previous_predicted)
242
+ source_start = _best_source_position(
243
+ waveform,
244
+ synthesized,
245
+ weight,
246
+ output_start=output_start,
247
+ current_end=current_end,
248
+ predicted_start=predicted_source,
249
+ previous_start=previous_source,
250
+ predicted_step=predicted_step,
251
+ frame_length=frame_length,
252
+ search_radius=search_radius,
253
+ )
254
+
255
+ output_end = output_start + frame_length
256
+ source_end = source_start + frame_length
257
+ synthesized[output_start:output_end] += (
258
+ waveform[source_start:source_end] * window
259
+ )
260
+ weight[output_start:output_end] += window
261
+ current_end = max(current_end, output_end)
262
+ previous_source = source_start
263
+ previous_predicted = predicted_source
264
+
265
+ valid = weight > 1.0e-7
266
+ if not valid.all():
267
+ raise RuntimeError("WSOLA overlap-add left uncovered output samples")
268
+ output = synthesized / weight
269
+ if not np.isfinite(output).all():
270
+ raise RuntimeError("WSOLA produced non-finite output")
271
+ return np.ascontiguousarray(output, dtype=np.float32)
tests/test_audio_artifact_gate.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from audio_artifact_gate import (
7
+ EchoSmearingGateConfig,
8
+ evaluate_echo_smearing,
9
+ )
10
+
11
+
12
+ SAMPLE_RATE = 16_000
13
+
14
+
15
+ def _speech_proxy(*, seed=7, seconds=3.0):
16
+ sample_count = int(SAMPLE_RATE * seconds)
17
+ time = np.arange(sample_count, dtype=np.float64) / SAMPLE_RATE
18
+ rng = np.random.default_rng(seed)
19
+ fundamental = (
20
+ 128.0
21
+ + 26.0 * np.sin(2.0 * np.pi * 0.37 * time)
22
+ + 9.0 * np.sin(2.0 * np.pi * 0.11 * time)
23
+ )
24
+ phase = 2.0 * np.pi * np.cumsum(fundamental) / SAMPLE_RATE
25
+ voiced = sum(
26
+ np.sin(harmonic * phase + 0.17 * harmonic) / harmonic**0.8
27
+ for harmonic in range(1, 18)
28
+ )
29
+ envelope = np.zeros_like(time)
30
+ for index, center in enumerate(np.linspace(0.20, seconds - 0.25, 8)):
31
+ width = 0.07 + 0.035 * ((index + seed) % 4)
32
+ amplitude = 0.60 + 0.20 * ((index + seed) % 3)
33
+ envelope += amplitude * np.exp(-0.5 * np.square((time - center) / width))
34
+ envelope = np.minimum(envelope, 1.0)
35
+ waveform = voiced * envelope + 0.035 * rng.standard_normal(sample_count) * envelope
36
+ return 0.60 * waveform / np.max(np.abs(waveform))
37
+
38
+
39
+ def _delayed_copy(waveform, *, delay_ms=73.0, gain=0.68):
40
+ delay = int(round(delay_ms * SAMPLE_RATE / 1000.0))
41
+ output = waveform.copy()
42
+ output[delay:] += gain * waveform[:-delay]
43
+ return output / max(1.0, np.max(np.abs(output)) / 0.90)
44
+
45
+
46
+ def _dense_smear(waveform):
47
+ rng = np.random.default_rng(44)
48
+ impulse = np.zeros(int(0.20 * SAMPLE_RATE), dtype=np.float64)
49
+ impulse[0] = 1.0
50
+ indices = np.arange(int(0.015 * SAMPLE_RATE), impulse.size, 31)
51
+ impulse[indices] = (
52
+ rng.choice((-1.0, 1.0), size=indices.size)
53
+ * 0.16
54
+ * np.exp(-indices / (0.09 * SAMPLE_RATE))
55
+ )
56
+ output = np.convolve(waveform, impulse, mode="full")[: waveform.size]
57
+ return output / max(1.0, np.max(np.abs(output)) / 0.90)
58
+
59
+
60
+ def _comb_filter(waveform):
61
+ impulse = np.zeros(int(0.15 * SAMPLE_RATE), dtype=np.float64)
62
+ impulse[0] = 1.0
63
+ for delay_seconds, gain in (
64
+ (0.025, 0.50),
65
+ (0.047, 0.38),
66
+ (0.071, 0.30),
67
+ (0.110, 0.20),
68
+ ):
69
+ impulse[int(round(delay_seconds * SAMPLE_RATE))] = gain
70
+ output = np.convolve(waveform, impulse, mode="full")[: waveform.size]
71
+ return output / max(1.0, np.max(np.abs(output)) / 0.90)
72
+
73
+
74
+ @pytest.mark.parametrize("seed", range(5))
75
+ def test_clean_nonstationary_speech_proxy_passes(seed):
76
+ diagnostics = evaluate_echo_smearing(_speech_proxy(seed=seed), SAMPLE_RATE)
77
+
78
+ assert diagnostics.passed
79
+ assert diagnostics.rejection_reasons == ()
80
+ assert diagnostics.delayed_copy_score < 0.33
81
+ assert diagnostics.smearing_score < 0.40
82
+ assert diagnostics.analysis_window_count >= 3
83
+
84
+
85
+ def test_clean_broadband_noise_passes():
86
+ rng = np.random.default_rng(1234)
87
+ noise = 0.08 * rng.standard_normal(3 * SAMPLE_RATE)
88
+
89
+ diagnostics = evaluate_echo_smearing(noise, SAMPLE_RATE)
90
+
91
+ assert diagnostics.passed
92
+ assert diagnostics.rejection_reasons == ()
93
+
94
+
95
+ @pytest.mark.parametrize("delay_ms", (48.0, 73.0, 121.0))
96
+ def test_strong_delayed_copy_is_rejected_and_delay_is_localized(delay_ms):
97
+ diagnostics = evaluate_echo_smearing(
98
+ _delayed_copy(_speech_proxy(), delay_ms=delay_ms),
99
+ SAMPLE_RATE,
100
+ )
101
+
102
+ assert not diagnostics.passed
103
+ assert "delayed_copy" in diagnostics.rejection_reasons
104
+ assert diagnostics.delayed_copy_score >= 0.45
105
+ assert diagnostics.dominant_delay_ms == pytest.approx(delay_ms, abs=0.20)
106
+
107
+
108
+ def test_moderate_current_smoke_like_comb_signature_is_rejected():
109
+ diagnostics = evaluate_echo_smearing(
110
+ _delayed_copy(_speech_proxy(), delay_ms=32.0, gain=0.10),
111
+ SAMPLE_RATE,
112
+ )
113
+
114
+ assert not diagnostics.passed
115
+ assert "delayed_copy" in diagnostics.rejection_reasons
116
+ assert diagnostics.cepstral_peak_ratio > 10.0
117
+
118
+
119
+ def test_dense_reverberant_smear_is_rejected_by_density_proxy():
120
+ diagnostics = evaluate_echo_smearing(_dense_smear(_speech_proxy()), SAMPLE_RATE)
121
+
122
+ assert not diagnostics.passed
123
+ assert "smearing" in diagnostics.rejection_reasons
124
+ assert diagnostics.smearing_score >= 0.40
125
+
126
+
127
+ def test_multi_tap_comb_filter_is_rejected():
128
+ diagnostics = evaluate_echo_smearing(_comb_filter(_speech_proxy()), SAMPLE_RATE)
129
+
130
+ assert not diagnostics.passed
131
+ assert "delayed_copy" in diagnostics.rejection_reasons
132
+
133
+
134
+ def test_scores_are_deterministic_and_amplitude_invariant():
135
+ waveform = _delayed_copy(_speech_proxy())
136
+ first = evaluate_echo_smearing(waveform, SAMPLE_RATE)
137
+ second = evaluate_echo_smearing(waveform.copy(), SAMPLE_RATE)
138
+ quiet = evaluate_echo_smearing(waveform * 0.125, SAMPLE_RATE)
139
+
140
+ assert first == second
141
+ assert quiet.delayed_copy_score == pytest.approx(
142
+ first.delayed_copy_score, abs=1.0e-10
143
+ )
144
+ assert quiet.smearing_score == pytest.approx(first.smearing_score, abs=1.0e-10)
145
+
146
+
147
+ @pytest.mark.parametrize(
148
+ ("audio", "sample_rate", "reason"),
149
+ (
150
+ ([], SAMPLE_RATE, "invalid_audio"),
151
+ (np.zeros((2, 8_000)), SAMPLE_RATE, "invalid_audio"),
152
+ (np.ones(SAMPLE_RATE, dtype=np.complex128), SAMPLE_RATE, "invalid_audio"),
153
+ (np.full(SAMPLE_RATE, np.nan), SAMPLE_RATE, "nonfinite_audio"),
154
+ (np.zeros(SAMPLE_RATE), SAMPLE_RATE, "silent_audio"),
155
+ (np.ones(1_000), SAMPLE_RATE, "insufficient_duration"),
156
+ (np.ones(SAMPLE_RATE), True, "invalid_sample_rate"),
157
+ (np.ones(SAMPLE_RATE), 7_999, "invalid_sample_rate"),
158
+ ),
159
+ )
160
+ def test_invalid_or_insufficient_inputs_fail_closed_with_finite_diagnostics(
161
+ audio,
162
+ sample_rate,
163
+ reason,
164
+ ):
165
+ diagnostics = evaluate_echo_smearing(audio, sample_rate)
166
+
167
+ assert not diagnostics.passed
168
+ assert diagnostics.rejection_reasons == (reason,)
169
+ for value in (
170
+ diagnostics.delayed_copy_score,
171
+ diagnostics.smearing_score,
172
+ diagnostics.dominant_delay_ms,
173
+ diagnostics.cepstral_peak_ratio,
174
+ diagnostics.cepstral_q99_ratio,
175
+ diagnostics.cepstral_dense_fraction,
176
+ diagnostics.duration_seconds,
177
+ diagnostics.input_rms,
178
+ ):
179
+ assert math.isfinite(value)
180
+
181
+
182
+ def test_constant_dc_has_no_active_speech_windows_and_fails_closed():
183
+ diagnostics = evaluate_echo_smearing(np.full(2 * SAMPLE_RATE, 0.25), SAMPLE_RATE)
184
+
185
+ assert not diagnostics.passed
186
+ assert diagnostics.rejection_reasons == ("insufficient_active_windows",)
187
+
188
+
189
+ def test_invalid_config_fails_closed_instead_of_raising():
190
+ diagnostics = evaluate_echo_smearing(
191
+ _speech_proxy(),
192
+ SAMPLE_RATE,
193
+ config=EchoSmearingGateConfig(delayed_copy_threshold=float("nan")),
194
+ )
195
+
196
+ assert not diagnostics.passed
197
+ assert diagnostics.rejection_reasons == ("invalid_config",)
198
+
199
+ malformed = evaluate_echo_smearing(
200
+ _speech_proxy(),
201
+ SAMPLE_RATE,
202
+ config=EchoSmearingGateConfig(min_duration_seconds="not-a-number"),
203
+ )
204
+ assert not malformed.passed
205
+ assert malformed.rejection_reasons == ("invalid_config",)
tests/test_audio_wsola.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from audio_wsola import wsola_time_stretch
5
+
6
+
7
+ SAMPLE_RATE = 48_000
8
+
9
+
10
+ def _voiced_fixture(seconds: float = 1.8) -> np.ndarray:
11
+ samples = int(round(seconds * SAMPLE_RATE))
12
+ time = np.arange(samples, dtype=np.float64) / SAMPLE_RATE
13
+ phase = 2.0 * np.pi * (190.0 * time + 18.0 * np.square(time))
14
+ envelope = 0.55 + 0.35 * np.sin(2.0 * np.pi * 2.3 * time)
15
+ waveform = envelope * (
16
+ 0.30 * np.sin(phase)
17
+ + 0.13 * np.sin(2.0 * phase + 0.2)
18
+ + 0.07 * np.sin(3.0 * phase + 0.7)
19
+ )
20
+ return np.asarray(waveform, dtype=np.float32)
21
+
22
+
23
+ def _dominant_f0(waveform: np.ndarray) -> float:
24
+ central = waveform[len(waveform) // 4 : 3 * len(waveform) // 4]
25
+ central = np.asarray(central, dtype=np.float64) * np.hanning(central.size)
26
+ spectrum = np.abs(np.fft.rfft(central))
27
+ frequencies = np.fft.rfftfreq(central.size, 1.0 / SAMPLE_RATE)
28
+ mask = (frequencies >= 150.0) & (frequencies <= 300.0)
29
+ return float(frequencies[mask][np.argmax(spectrum[mask])])
30
+
31
+
32
+ @pytest.mark.parametrize("rate", (0.90, 0.95, 1.05))
33
+ def test_wsola_is_deterministic_finite_and_has_exact_length(rate):
34
+ waveform = _voiced_fixture()
35
+
36
+ first = wsola_time_stretch(
37
+ waveform,
38
+ rate=rate,
39
+ sample_rate=SAMPLE_RATE,
40
+ )
41
+ second = wsola_time_stretch(
42
+ waveform,
43
+ rate=rate,
44
+ sample_rate=SAMPLE_RATE,
45
+ )
46
+
47
+ assert first.dtype == np.float32
48
+ assert first.ndim == 1
49
+ assert first.flags.c_contiguous
50
+ assert first.size == round(waveform.size / rate)
51
+ assert np.isfinite(first).all()
52
+ assert np.array_equal(first, second)
53
+
54
+
55
+ @pytest.mark.parametrize("rate", (0.90, 0.95, 1.05))
56
+ def test_wsola_preserves_voiced_pitch(rate):
57
+ waveform = _voiced_fixture()
58
+ stretched = wsola_time_stretch(
59
+ waveform,
60
+ rate=rate,
61
+ sample_rate=SAMPLE_RATE,
62
+ )
63
+
64
+ source_f0 = _dominant_f0(waveform)
65
+ stretched_f0 = _dominant_f0(stretched)
66
+ assert abs(stretched_f0 - source_f0) / source_f0 < 0.015
67
+
68
+
69
+ def test_wsola_near_unity_is_bit_exact_copy():
70
+ waveform = _voiced_fixture(0.5)
71
+
72
+ stretched = wsola_time_stretch(
73
+ waveform,
74
+ rate=1.0005,
75
+ sample_rate=SAMPLE_RATE,
76
+ )
77
+
78
+ assert stretched is not waveform
79
+ assert np.array_equal(stretched, waveform)
80
+
81
+
82
+ def test_wsola_preserves_transient_count_and_endpoint_transients():
83
+ rng = np.random.default_rng(31)
84
+ waveform = np.asarray(
85
+ 0.002 * rng.standard_normal(2 * SAMPLE_RATE),
86
+ dtype=np.float32,
87
+ )
88
+ transient_positions = (200, 12_000, 34_000, 60_000, waveform.size - 201)
89
+ transient = np.asarray((0.08, 0.40, 0.80, 0.40, 0.08), dtype=np.float32)
90
+ for position in transient_positions:
91
+ waveform[position - 2 : position + 3] += transient
92
+
93
+ stretched = wsola_time_stretch(
94
+ waveform,
95
+ rate=0.90,
96
+ sample_rate=SAMPLE_RATE,
97
+ )
98
+ above_threshold = np.flatnonzero(np.abs(stretched) >= 0.30)
99
+ groups = np.split(
100
+ above_threshold,
101
+ np.flatnonzero(np.diff(above_threshold) > 500) + 1,
102
+ )
103
+
104
+ assert len(groups) == len(transient_positions)
105
+ assert int(groups[0][0]) < 500
106
+ assert int(groups[-1][-1]) > stretched.size - 500
107
+ assert all(float(np.max(np.abs(stretched[group]))) >= 0.50 for group in groups)
108
+
109
+
110
+ @pytest.mark.parametrize(
111
+ ("audio", "rate", "sample_rate"),
112
+ (
113
+ (np.zeros(128, dtype=np.float32), 0.90, SAMPLE_RATE),
114
+ (np.zeros((1024, 2), dtype=np.float32), 0.90, SAMPLE_RATE),
115
+ (np.full(1024, np.nan, dtype=np.float32), 0.90, SAMPLE_RATE),
116
+ (np.zeros(1024, dtype=np.float32), 0.0, SAMPLE_RATE),
117
+ (np.zeros(1024, dtype=np.float32), 0.49, SAMPLE_RATE),
118
+ (np.zeros(1024, dtype=np.float32), 2.01, SAMPLE_RATE),
119
+ (np.zeros(1024, dtype=np.float32), 0.90, 0),
120
+ ),
121
+ )
122
+ def test_wsola_rejects_invalid_inputs(audio, rate, sample_rate):
123
+ with pytest.raises(ValueError):
124
+ wsola_time_stretch(
125
+ audio,
126
+ rate=rate,
127
+ sample_rate=sample_rate,
128
+ )
tests/test_inference_cleanup.py CHANGED
@@ -42,6 +42,121 @@ def _isolated_app_function(name, namespace):
42
  return namespace[name]
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def test_closed_loop_pace_rerenders_once_from_untouched_model_waveform():
46
  apply_calls = []
47
  active_calls = []
@@ -70,7 +185,7 @@ def test_closed_loop_pace_rerenders_once_from_untouched_model_waveform():
70
  "endpoint_generation_plan": lambda *args, **kwargs: ("測試內容。", 10, 12),
71
  "effective_generation_cfg": lambda text, cfg, **kwargs: cfg,
72
  "set_generation_seed": lambda seed: None,
73
- "target_pace_speed": lambda *args, **kwargs: 0.90,
74
  "active_voiced_duration_seconds": active_duration,
75
  "active_pace_correction_speed": active_correction,
76
  "endpoint_tail_energy_ratio": lambda audio, sample_rate: 0.5,
@@ -87,7 +202,7 @@ def test_closed_loop_pace_rerenders_once_from_untouched_model_waveform():
87
  "TARGET_CPS": 4.0,
88
  "ACTIVE_PACE_TARGET_CPS": 4.0,
89
  "CLOSED_LOOP_ACTIVE_PACE_TARGET_CPS": 3.95,
90
- "MIN_PACE_SPEED": 0.80,
91
  "STOP_THRESHOLD": 0.50,
92
  "STOP_CONSECUTIVE": 1,
93
  }
@@ -112,12 +227,12 @@ def test_closed_loop_pace_rerenders_once_from_untouched_model_waveform():
112
  assert len(apply_calls) == 2
113
  assert np.array_equal(apply_calls[0][0], np.ones(16, dtype=np.float32))
114
  assert np.array_equal(apply_calls[1][0], np.ones(16, dtype=np.float32))
115
- assert apply_calls[0][1] == pytest.approx(0.90)
116
- assert apply_calls[1][1] == pytest.approx(0.90 * 0.95)
117
  assert all(call[2] is True for call in apply_calls)
118
  assert np.array_equal(
119
  output.audio,
120
- np.ones(16, dtype=np.float32) * np.float32(0.855),
121
  )
122
  assert output.stop_reason == "native_stop"
123
  assert output.endpoint_energy_ratio == 0.5
@@ -125,11 +240,11 @@ def test_closed_loop_pace_rerenders_once_from_untouched_model_waveform():
125
  assert output.hard_stop_steps == 12
126
  assert len(active_calls) == 2
127
  assert np.array_equal(active_calls[0][0], np.ones(16, dtype=np.float32))
128
- assert np.array_equal(active_calls[1][0], np.ones(16, dtype=np.float32) * 0.90)
129
  assert correction_calls[1][2] == {
130
  "target_cps": 3.95,
131
- "prior_speed": 0.90,
132
- "min_total_speed": 0.80,
133
  }
134
 
135
 
@@ -171,7 +286,7 @@ def test_sparse_completion_policy_adds_only_bounded_one_two_unit_headroom():
171
  "TARGET_CPS": 4.0,
172
  "ACTIVE_PACE_TARGET_CPS": 4.0,
173
  "CLOSED_LOOP_ACTIVE_PACE_TARGET_CPS": 3.95,
174
- "MIN_PACE_SPEED": 0.80,
175
  "STOP_THRESHOLD": 0.50,
176
  "STOP_CONSECUTIVE": 1,
177
  }
 
42
  return namespace[name]
43
 
44
 
45
+ def test_app_speed_wrapper_uses_wsola_and_rejects_below_release_floor():
46
+ calls = []
47
+
48
+ def stretch(audio, *, rate, sample_rate):
49
+ calls.append((np.asarray(audio).copy(), rate, sample_rate))
50
+ return np.full(12, rate, dtype=np.float32)
51
+
52
+ apply_speed = _isolated_app_function(
53
+ "_apply_speed",
54
+ {
55
+ "np": np,
56
+ "wsola_time_stretch": stretch,
57
+ "MIN_PACE_SPEED": 0.90,
58
+ "SR": 48_000,
59
+ },
60
+ )
61
+ waveform = np.linspace(-0.5, 0.5, 16, dtype=np.float32)
62
+
63
+ output = apply_speed(waveform, 0.90, network_conditioned=True)
64
+
65
+ assert output.dtype == np.float32
66
+ assert np.array_equal(output, np.full(12, 0.90, dtype=np.float32))
67
+ assert len(calls) == 1
68
+ assert np.array_equal(calls[0][0], waveform)
69
+ assert calls[0][1:] == (0.90, 48_000)
70
+ with pytest.raises(ValueError, match="below the production WSOLA floor"):
71
+ apply_speed(waveform, 0.899)
72
+ with pytest.raises(ValueError, match="finite and positive"):
73
+ apply_speed(waveform, float("nan"))
74
+
75
+
76
+ def test_echo_smearing_gate_projects_acoustic_failure_into_trajectory():
77
+ base = verify_trajectory(
78
+ (
79
+ CandidateObservation(
80
+ target_text="測試內容",
81
+ transcript_text="測試內容",
82
+ audio_duration_seconds=2.0,
83
+ ),
84
+ ),
85
+ speaker_gate_enabled=False,
86
+ )
87
+ assert base.passed
88
+ diagnostics = SimpleNamespace(
89
+ passed=False,
90
+ rejection_reasons=("delayed_copy",),
91
+ delayed_copy_score=0.6,
92
+ smearing_score=0.1,
93
+ dominant_delay_ms=42.0,
94
+ cepstral_peak_ratio=11.0,
95
+ analysis_window_count=5,
96
+ )
97
+ gate = _isolated_app_function(
98
+ "_apply_echo_smearing_gate",
99
+ {
100
+ "np": np,
101
+ "replace": replace,
102
+ "SR": 48_000,
103
+ "DEFAULT_ECHO_SMEARING_GATE_CONFIG": SimpleNamespace(
104
+ min_duration_seconds=1.0
105
+ ),
106
+ "evaluate_echo_smearing": lambda waveform, sample_rate: diagnostics,
107
+ },
108
+ )
109
+
110
+ rejected = gate(
111
+ base,
112
+ (np.ones(2 * 48_000, dtype=np.float32),),
113
+ )
114
+
115
+ assert not rejected.passed
116
+ assert np.isinf(rejected.score)
117
+ assert rejected.rejection_reasons == (
118
+ "chunk_0:audio_artifact_delayed_copy",
119
+ )
120
+ assert rejected.candidate_results[0].rejection_reasons == (
121
+ "audio_artifact_delayed_copy",
122
+ )
123
+
124
+
125
+ def test_echo_smearing_gate_does_not_apply_estimator_below_one_second():
126
+ base = verify_trajectory(
127
+ (
128
+ CandidateObservation(
129
+ target_text="短句",
130
+ transcript_text="短句",
131
+ audio_duration_seconds=0.8,
132
+ ),
133
+ ),
134
+ speaker_gate_enabled=False,
135
+ )
136
+ calls = []
137
+ gate = _isolated_app_function(
138
+ "_apply_echo_smearing_gate",
139
+ {
140
+ "np": np,
141
+ "replace": replace,
142
+ "SR": 48_000,
143
+ "DEFAULT_ECHO_SMEARING_GATE_CONFIG": SimpleNamespace(
144
+ min_duration_seconds=1.0
145
+ ),
146
+ "evaluate_echo_smearing": lambda *args: calls.append(args),
147
+ },
148
+ )
149
+
150
+ unchanged = gate(
151
+ base,
152
+ (np.ones(int(0.8 * 48_000), dtype=np.float32),),
153
+ )
154
+
155
+ assert unchanged.passed
156
+ assert unchanged.rejection_reasons == ()
157
+ assert calls == []
158
+
159
+
160
  def test_closed_loop_pace_rerenders_once_from_untouched_model_waveform():
161
  apply_calls = []
162
  active_calls = []
 
185
  "endpoint_generation_plan": lambda *args, **kwargs: ("測試內容。", 10, 12),
186
  "effective_generation_cfg": lambda text, cfg, **kwargs: cfg,
187
  "set_generation_seed": lambda seed: None,
188
+ "target_pace_speed": lambda *args, **kwargs: 0.95,
189
  "active_voiced_duration_seconds": active_duration,
190
  "active_pace_correction_speed": active_correction,
191
  "endpoint_tail_energy_ratio": lambda audio, sample_rate: 0.5,
 
202
  "TARGET_CPS": 4.0,
203
  "ACTIVE_PACE_TARGET_CPS": 4.0,
204
  "CLOSED_LOOP_ACTIVE_PACE_TARGET_CPS": 3.95,
205
+ "MIN_PACE_SPEED": 0.90,
206
  "STOP_THRESHOLD": 0.50,
207
  "STOP_CONSECUTIVE": 1,
208
  }
 
227
  assert len(apply_calls) == 2
228
  assert np.array_equal(apply_calls[0][0], np.ones(16, dtype=np.float32))
229
  assert np.array_equal(apply_calls[1][0], np.ones(16, dtype=np.float32))
230
+ assert apply_calls[0][1] == pytest.approx(0.95)
231
+ assert apply_calls[1][1] == pytest.approx(0.95 * 0.95)
232
  assert all(call[2] is True for call in apply_calls)
233
  assert np.array_equal(
234
  output.audio,
235
+ np.ones(16, dtype=np.float32) * np.float32(0.9025),
236
  )
237
  assert output.stop_reason == "native_stop"
238
  assert output.endpoint_energy_ratio == 0.5
 
240
  assert output.hard_stop_steps == 12
241
  assert len(active_calls) == 2
242
  assert np.array_equal(active_calls[0][0], np.ones(16, dtype=np.float32))
243
+ assert np.array_equal(active_calls[1][0], np.ones(16, dtype=np.float32) * 0.95)
244
  assert correction_calls[1][2] == {
245
  "target_cps": 3.95,
246
+ "prior_speed": 0.95,
247
+ "min_total_speed": 0.90,
248
  }
249
 
250
 
 
286
  "TARGET_CPS": 4.0,
287
  "ACTIVE_PACE_TARGET_CPS": 4.0,
288
  "CLOSED_LOOP_ACTIVE_PACE_TARGET_CPS": 3.95,
289
+ "MIN_PACE_SPEED": 0.90,
290
  "STOP_THRESHOLD": 0.50,
291
  "STOP_CONSECUTIVE": 1,
292
  }
tests/test_release_pins.py CHANGED
@@ -1035,14 +1035,15 @@ def test_chunk_generation_closed_loop_rerenders_final_audio_from_raw_once():
1035
  assert "final_speed > EXTREME_SHORT_MAX_SPEED" in generate_source
1036
  assert "final_speed = EXTREME_SHORT_MAX_SPEED" in generate_source
1037
  assert "audio,\n final_speed,\n network_conditioned=False" in generate_source
1038
- assert "PACE_STRETCH_N_FFT = 1536" in source
1039
- assert "PACE_STRETCH_HOP_LENGTH = 384" in source
1040
- assert "NETWORK_PACE_STRETCH_N_FFT = 2048" in source
1041
- assert "NETWORK_PACE_STRETCH_HOP_LENGTH = 512" in source
1042
  assert "network_conditioned=network_conditioned" in generate_source
1043
- assert "if network_conditioned" in apply_speed_source
1044
- assert "n_fft=n_fft" in apply_speed_source
1045
- assert "hop_length=hop_length" in apply_speed_source
 
 
1046
 
1047
 
1048
  def test_public_tts_wrappers_serialize_the_same_pcm16_grid_used_for_verification():
@@ -1097,6 +1098,14 @@ def test_space_applies_pinned_squim_to_local_joined_and_final_audio():
1097
  observation_index = verify_source.index("CandidateObservation(", score_index)
1098
  gate_index = verify_source.index("squim_gate_enabled=not semantic_only")
1099
  assert score_index < observation_index < gate_index
 
 
 
 
 
 
 
 
1100
  assert "except ValueError:" in verify_source[score_index:observation_index]
1101
  assert "except RuntimeError:" not in verify_source[score_index:observation_index]
1102
  assert (
@@ -1509,7 +1518,8 @@ def test_space_locks_validated_nfe_and_bounds_total_generation_work():
1509
  assert "EMAIL_MAIL_FALLBACK_CANDIDATE_ORDINALS = frozenset((2, 5, 6))" in source
1510
  assert "NETWORK_REQUEST_ORDINARY_MAX_UNITS = 36" in source
1511
  assert "ordinary_max_units=NETWORK_REQUEST_ORDINARY_MAX_UNITS" in source
1512
- assert "PACE_ONLY_FALLBACK_MIN_SPEED = 0.76" in source
 
1513
  assert "PACE_ONLY_FALLBACK_MIN_UNITS = 30" in source
1514
  assert "if observed_cps > QUALITY_MAX_PACE_CPS:" in source
1515
  assert "final_audio = _apply_speed(" in source
@@ -1526,6 +1536,8 @@ def test_space_locks_validated_nfe_and_bounds_total_generation_work():
1526
  assert "requested_steps != DEFAULT_STEPS" in source
1527
  assert "interactive=False" in source
1528
  assert "NFE steps(已驗證固定值)" in source
 
 
1529
  assert "最多 3 條 culprit-diverse 完整路徑" in readme
1530
  assert "不再為了取得某一段替代候選而重生整篇" in readme
1531
  assert "boundary-only relaxation" in readme
 
1035
  assert "final_speed > EXTREME_SHORT_MAX_SPEED" in generate_source
1036
  assert "final_speed = EXTREME_SHORT_MAX_SPEED" in generate_source
1037
  assert "audio,\n final_speed,\n network_conditioned=False" in generate_source
1038
+ assert "MIN_PACE_SPEED = 0.90" in source
1039
+ assert "PACE_ONLY_FALLBACK_MIN_SPEED = MIN_PACE_SPEED" in source
1040
+ assert "from audio_wsola import wsola_time_stretch" in source
 
1041
  assert "network_conditioned=network_conditioned" in generate_source
1042
+ assert "del network_conditioned" in apply_speed_source
1043
+ assert "speed < MIN_PACE_SPEED" in apply_speed_source
1044
+ assert "wsola_time_stretch(" in apply_speed_source
1045
+ assert "sample_rate=SR" in apply_speed_source
1046
+ assert "librosa.effects.time_stretch" not in apply_speed_source
1047
 
1048
 
1049
  def test_public_tts_wrappers_serialize_the_same_pcm16_grid_used_for_verification():
 
1098
  observation_index = verify_source.index("CandidateObservation(", score_index)
1099
  gate_index = verify_source.index("squim_gate_enabled=not semantic_only")
1100
  assert score_index < observation_index < gate_index
1101
+ artifact_gate_index = verify_source.index(
1102
+ "verification = _apply_echo_smearing_gate("
1103
+ )
1104
+ assert gate_index < artifact_gate_index
1105
+ assert "if not semantic_only:" in verify_source[gate_index:artifact_gate_index]
1106
+ assert "tuple(prepared_waveforms)" in verify_source[artifact_gate_index:]
1107
+ assert "from audio_artifact_gate import (" in app_source
1108
+ assert "evaluate_echo_smearing," in app_source
1109
  assert "except ValueError:" in verify_source[score_index:observation_index]
1110
  assert "except RuntimeError:" not in verify_source[score_index:observation_index]
1111
  assert (
 
1518
  assert "EMAIL_MAIL_FALLBACK_CANDIDATE_ORDINALS = frozenset((2, 5, 6))" in source
1519
  assert "NETWORK_REQUEST_ORDINARY_MAX_UNITS = 36" in source
1520
  assert "ordinary_max_units=NETWORK_REQUEST_ORDINARY_MAX_UNITS" in source
1521
+ assert "MIN_PACE_SPEED = 0.90" in source
1522
+ assert "PACE_ONLY_FALLBACK_MIN_SPEED = MIN_PACE_SPEED" in source
1523
  assert "PACE_ONLY_FALLBACK_MIN_UNITS = 30" in source
1524
  assert "if observed_cps > QUALITY_MAX_PACE_CPS:" in source
1525
  assert "final_audio = _apply_speed(" in source
 
1536
  assert "requested_steps != DEFAULT_STEPS" in source
1537
  assert "interactive=False" in source
1538
  assert "NFE steps(已驗證固定值)" in source
1539
+ assert 'label="後處理語速(已驗證固定值)"' in source
1540
+ assert "float(speed) != 1.0" in source
1541
  assert "最多 3 條 culprit-diverse 完整路徑" in readme
1542
  assert "不再為了取得某一段替代候選而重生整篇" in readme
1543
  assert "boundary-only relaxation" in readme