File size: 10,410 Bytes
5eee449 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | #!/usr/bin/env python3
"""Host-chain unit tests for the Inflect AX TTS Python SDK.
Runs on a plain host without NPU: onnxruntime is used as a numeric stand-in
for the AXMODEL runtime (the SDK pipeline above the session layer is identical
for both backends; see README.md for what is NOT covered by this).
Runnable two ways:
python tests/test_host_chain.py # plain script, exit code 0 on pass
pytest tests/ # if pytest is installed
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
SDK_ROOT = Path(__file__).resolve().parents[1]
PKG_DIR = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(SDK_ROOT))
from inflect_ax_tts import DUMMY_PHONEME_IDS, InflectTTS # noqa: E402
from inflect_ax_tts import host_chain # noqa: E402
from inflect_ax_tts.frontend import intersperse # noqa: E402
CONVERT_DIR = PKG_DIR / "model_convert"
REF_NPZ = CONVERT_DIR / "export" / "reference_io.npz"
ENCODER_ONNX = CONVERT_DIR / "export" / "encoder.onnx"
DECODER_ONNX = CONVERT_DIR / "export" / "decoder.onnx"
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
a = np.asarray(a, dtype=np.float64).ravel()
b = np.asarray(b, dtype=np.float64).ravel()
assert a.shape == b.shape, f"shape {a.shape} vs {b.shape}"
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-30))
def _torch_reference_expand(logw, m_p, logs_p, x_len, length_scale):
"""Replicates export/export_onnx.py:host_expand with torch (ground truth)."""
import torch
def sequence_mask(lengths, max_length):
return torch.arange(max_length).unsqueeze(0) < lengths.unsqueeze(1)
x_lengths = torch.LongTensor([x_len])
x_mask = sequence_mask(x_lengths, logw.shape[-1]).unsqueeze(1).float()
w = torch.exp(torch.from_numpy(logw)) * x_mask * length_scale
w_ceil = torch.ceil(w)
y_lengths = torch.clamp_min(torch.sum(w_ceil, [1, 2]), 1).long()
y_len = int(y_lengths.item())
y_mask = sequence_mask(y_lengths, y_len).unsqueeze(1).float()
attn_mask = x_mask.unsqueeze(2) * y_mask.unsqueeze(-1)
# generate_path (origin/runtime/commons.py)
cum = torch.cumsum(w_ceil, -1).view(-1)
path = sequence_mask(cum, y_len).float().view(1, 1, -1, y_len)
path = path - torch.nn.functional.pad(path, (0, 0, 1, 0))[:, :, :-1]
attn = path.transpose(2, 3) * attn_mask # [1, 1, T', T]
m_p_e = torch.matmul(attn.squeeze(1), torch.from_numpy(m_p).transpose(1, 2))
logs_p_e = torch.matmul(attn.squeeze(1), torch.from_numpy(logs_p).transpose(1, 2))
return m_p_e[0].numpy(), logs_p_e[0].numpy(), y_len
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_expand_priors_matches_torch_reference():
"""numpy host chain (durations + generate_path + matmul) vs torch."""
ref = np.load(REF_NPZ)
logw, m_p, logs_p = ref["logw_torch"], ref["m_p_torch"], ref["logs_p_torch"]
x_len = int(ref["x_lengths"][0])
for length_scale in (1.0, 0.8, 1.6):
exp_m, exp_l, y_len = host_chain.expand_priors(
logw, m_p, logs_p, x_len, length_scale
)
ref_m, ref_l, ref_y = _torch_reference_expand(
logw, m_p, logs_p, x_len, length_scale
)
assert y_len == ref_y, f"T' {y_len} vs torch {ref_y}"
assert np.allclose(exp_m, ref_m, atol=1e-5), (
f"m_p_e max diff {np.abs(exp_m - ref_m).max():.2e} @ls={length_scale}"
)
assert np.allclose(exp_l, ref_l, atol=1e-5), (
f"logs_p_e max diff {np.abs(exp_l - ref_l).max():.2e} @ls={length_scale}"
)
print("[PASS] expand_priors == torch host_expand (3 length scales)")
def test_encoder_padding_semantics_ort():
"""Padded static-shape encoder call == unpadded reference (first x_len frames)."""
import onnxruntime as ort
ref = np.load(REF_NPZ)
tokens = np.zeros((1, 256), dtype=np.int64)
x_len = int(ref["x_lengths"][0])
tokens[0, :x_len] = ref["tokens"][0]
sess = ort.InferenceSession(str(ENCODER_ONNX), providers=["CPUExecutionProvider"])
m_p, logs_p, logw = sess.run(
None, {"tokens": tokens, "x_lengths": np.asarray([x_len], dtype=np.int64)}
)
for name, got, want in (
("m_p", m_p, ref["m_p_torch"]),
("logs_p", logs_p, ref["logs_p_torch"]),
("logw", logw, ref["logw_torch"]),
):
cos = _cosine(got[..., :x_len], want[..., :x_len])
assert cos >= 0.999999, f"{name} cos {cos}"
print("[PASS] encoder T=256 padding: first-x_len frames match torch ref")
def test_decoder_chunk_compose_ort():
"""SDK chunked decode (Tp=512 path) vs EXPORT reference chain wav."""
import onnxruntime as ort
ref = np.load(REF_NPZ)
z_p = ref["z_p"][0] # [192, 51]
sess = ort.InferenceSession(str(DECODER_ONNX), providers=["CPUExecutionProvider"])
def run_chunk(z_chunk: np.ndarray) -> np.ndarray:
return sess.run(None, {"z_p": z_chunk[None].astype(np.float32)})[0].reshape(-1)
wav = host_chain.decode_waveform(z_p, run_chunk)
want = ref["wav_onnx_chain"].reshape(-1)
assert wav.shape == want.shape, f"{wav.shape} vs {want.shape}"
# Right zero-padding bleeds into the trailing frames (flow/dec conv
# receptive field; SIMULATE §4 uses the same metric convention): crop
# min(64, T'/4) frames off the right edge for the comparison.
t_prime = z_p.shape[1]
crop = min(64, t_prime // 4) * host_chain.HOP_LENGTH
cos = _cosine(wav[:-crop], want[:-crop])
assert cos >= 0.9999, f"decoder compose cos {cos}"
head = _cosine(wav[: (t_prime // 2) * host_chain.HOP_LENGTH],
want[: (t_prime // 2) * host_chain.HOP_LENGTH])
assert head >= 0.999999, f"decoder compose head cos {head}"
print(f"[PASS] decoder chunk compose vs EXPORT chain: cos={cos:.9f} (right-edge crop), head cos={head:.9f}")
def test_decoder_multi_chunk_compose_ort():
"""T'>512 multi-chunk path: overlap crossfade vs single dynamic ORT call.
Uses synthetic z_p (not a real prior): checks stitching correctness only,
comparing against the dynamic-shape ONNX decoder run over the full length.
Boundary regions are excluded from the metric (conv receptive-field bleed
is expected; EXPORT_NOTES §5.3).
"""
import onnxruntime as ort
rng = np.random.default_rng(1234)
t_prime = 512 + 300
z_p = rng.standard_normal((192, t_prime), dtype=np.float32) * 0.5
sess = ort.InferenceSession(str(DECODER_ONNX), providers=["CPUExecutionProvider"])
want = sess.run(None, {"z_p": z_p[None]})[0].reshape(-1)
got = host_chain.decode_waveform(
z_p, lambda z: sess.run(None, {"z_p": z[None]})[0].reshape(-1)
)
assert got.shape == want.shape
# Exclude 32 frames around the chunk boundary (frame 512) and the edges.
hop = host_chain.HOP_LENGTH
lo, hi = (512 + 32) * hop, (t_prime - 32) * hop
interior = _cosine(got[lo:hi], want[lo:hi])
head = _cosine(got[: 480 * hop], want[: 480 * hop])
assert interior >= 0.999, f"multi-chunk interior cos {interior}"
assert head >= 0.999, f"multi-chunk head cos {head}"
print(f"[PASS] multi-chunk compose: head cos={head:.6f}, interior cos={interior:.6f}")
def test_synthesize_tokens_end_to_end_ort():
"""Full SDK pipeline on ORT stand-in: length, determinism, finiteness."""
tts = InflectTTS(ENCODER_ONNX, DECODER_ONNX, backend="onnxruntime")
sr, wav1 = tts.synthesize_tokens(DUMMY_PHONEME_IDS, seed=0, variation=0.667)
_, wav2 = tts.synthesize_tokens(DUMMY_PHONEME_IDS, seed=0, variation=0.667)
_, wav3 = tts.synthesize_tokens(DUMMY_PHONEME_IDS, seed=1, variation=0.667)
ref = np.load(REF_NPZ)
assert sr == 24000
# Same Host chain as EXPORT baseline -> same T'=51 -> 13056 samples.
assert wav1.size == int(ref["wav_onnx_chain"].size), (
f"{wav1.size} vs baseline {ref['wav_onnx_chain'].size}"
)
assert np.array_equal(wav1, wav2), "same seed must be bit-reproducible"
assert not np.array_equal(wav1, wav3), "different seed must differ"
assert np.isfinite(wav1).all()
assert np.abs(wav1).max() <= 1.0
print(f"[PASS] e2e synthesize_tokens: {wav1.size} samples, deterministic, bounded")
def test_frontend_symbol_mapping():
"""Symbol table mapping: literal IPA baseline string -> EXPORT baseline ids.
export/model_meta.json numeric_baseline was built with
cleaned_text_to_sequence("ðə kwɪk") — a literal IPA string, NOT eSpeak
output (eSpeak with_stress would add stress marks)."""
from inflect_ax_tts.symbols import symbols
symbol_to_id = {s: i for i, s in enumerate(symbols)}
ids = [symbol_to_id[ch] for ch in "ðə kwɪk"]
assert ids == DUMMY_PHONEME_IDS, f"{ids} vs {DUMMY_PHONEME_IDS}"
assert intersperse(ids, 0) == [0, 81, 0, 83, 0, 16, 0, 53, 0, 65, 0, 102, 0, 53, 0]
print("[PASS] symbol mapping: 'ðə kwɪk' -> baseline phoneme ids")
def test_frontend_smoke():
"""eSpeak frontend structural check (skipped when espeak is unavailable)."""
try:
from inflect_ax_tts.frontend import text_to_token_ids
ids = text_to_token_ids("the quick")
except Exception as exc: # espeak missing on host -> skip, not a failure
print(f"[SKIP] frontend (espeak unavailable: {exc})")
return
assert len(ids) % 2 == 1, "interspersed sequence must be odd length"
assert ids[::2] == [0] * (len(ids) // 2 + 1), "blanks must interleave"
from inflect_ax_tts.symbols import symbols
assert all(0 <= i < len(symbols) for i in ids)
print(f"[PASS] frontend smoke: 'the quick' -> {len(ids)} interspersed ids")
ALL_TESTS = [
test_expand_priors_matches_torch_reference,
test_encoder_padding_semantics_ort,
test_decoder_chunk_compose_ort,
test_decoder_multi_chunk_compose_ort,
test_synthesize_tokens_end_to_end_ort,
test_frontend_symbol_mapping,
test_frontend_smoke,
]
def main() -> int:
failures = 0
for test in ALL_TESTS:
try:
test()
except Exception as exc: # noqa: BLE001
failures += 1
print(f"[FAIL] {test.__name__}: {exc}")
print(f"{'ALL PASS' if not failures else f'{failures} FAILURES'} ({len(ALL_TESTS)} tests)")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
|