Spaces:
Running on Zero
Running on Zero
Replace phase vocoder with guarded WSOLA
Browse files- README.md +13 -7
- app.py +125 -26
- audio_artifact_gate.py +439 -0
- audio_wsola.py +271 -0
- tests/test_audio_artifact_gate.py +205 -0
- tests/test_audio_wsola.py +128 -0
- tests/test_inference_cleanup.py +124 -9
- tests/test_release_pins.py +20 -8
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 |
-
不會串接多次
|
| 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 因此
|
| 308 |
-
固定
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 248 |
EXTREME_SHORT_MAX_UNITS = 2
|
| 249 |
EXTREME_SHORT_MAX_SPEED = 0.95
|
| 250 |
-
PACE_ONLY_FALLBACK_MIN_SPEED =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
if abs(speed - 1.0) < 1.0e-3:
|
| 535 |
-
return audio
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
)
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
)
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 2039 |
-
raise gr.Error("後處理語速
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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.
|
| 116 |
-
assert apply_calls[1][1] == pytest.approx(0.
|
| 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.
|
| 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.
|
| 129 |
assert correction_calls[1][2] == {
|
| 130 |
"target_cps": 3.95,
|
| 131 |
-
"prior_speed": 0.
|
| 132 |
-
"min_total_speed": 0.
|
| 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.
|
| 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 "
|
| 1039 |
-
assert "
|
| 1040 |
-
assert "
|
| 1041 |
-
assert "NETWORK_PACE_STRETCH_HOP_LENGTH = 512" in source
|
| 1042 |
assert "network_conditioned=network_conditioned" in generate_source
|
| 1043 |
-
assert "
|
| 1044 |
-
assert "
|
| 1045 |
-
assert "
|
|
|
|
|
|
|
| 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 "
|
|
|
|
| 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
|