WolfDavid commited on
Commit
371c5a6
·
1 Parent(s): 0284ddd

test(01-06): add failing tests for the vowel table and banker's frame quantisation

Browse files

- 13-entry VOWEL_TO_VISEME including the uppercase devoiced A I U E O
- to_frame() proven round-half-to-even against 8 exact-half vectors

Files changed (1) hide show
  1. tests/test_visemes.py +101 -0
tests/test_visemes.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AVTR-02 and VOIC-03: the viseme timeline builder.
2
+
3
+ This is the densest test file in Phase 1 because ``visemes.py`` is the one function whose
4
+ failure modes are all silent. A wrong rounding mode, a lowercase-only vowel table or a
5
+ float-accumulating loop each produce a timeline that looks entirely plausible in a debugger and
6
+ visibly wrong on the avatar's face.
7
+
8
+ Every fixture here was captured from a real ``voicevox_core`` 0.17.0 synthesis by
9
+ ``tests/fixtures/make_synth_fixtures.py``; every duration compared against is read from the WAV
10
+ header, never summed from the query.
11
+
12
+ Nothing in this file imports ``voicevox_core`` - the builder is a pure function over plain dicts,
13
+ so the quick loop stays installable-free and fast.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+
20
+ import pytest
21
+
22
+ from japanese_avatar.voice.visemes import (
23
+ DEVOICED,
24
+ FRAMERATE,
25
+ VOWEL_TO_VISEME,
26
+ to_frame,
27
+ viseme_for,
28
+ )
29
+
30
+ # frames -> to_frame(frames / FRAMERATE). Round-half-up would give the third column.
31
+ # Verified to round-trip exactly through f / 93.75 * 93.75 on CPython.
32
+ BANKERS_VECTORS = [
33
+ (0.5, 0, 1),
34
+ (1.5, 2, 2),
35
+ (2.5, 2, 3),
36
+ (4.5, 4, 5),
37
+ (5.5, 6, 6),
38
+ (10.5, 10, 11),
39
+ (20.5, 20, 21),
40
+ (100.5, 100, 101),
41
+ ]
42
+
43
+
44
+ def test_vowel_mapping():
45
+ """AVTR-02: every VOICEVOX vowel symbol, including the devoiced uppercase ones."""
46
+ assert VOWEL_TO_VISEME["a"] == "aa"
47
+ assert VOWEL_TO_VISEME["i"] == "ih"
48
+ assert VOWEL_TO_VISEME["u"] == "ou"
49
+ assert VOWEL_TO_VISEME["e"] == "ee"
50
+ assert VOWEL_TO_VISEME["o"] == "oh"
51
+
52
+ # Japanese devoices /i/ and /u/ between voiceless consonants constantly - です is
53
+ # "d e s U", した is "sh I t a". VOICEVOX emits those as UPPERCASE. A lowercase-only
54
+ # table freezes the mouth on every polite form.
55
+ for v in "AIUEO":
56
+ assert VOWEL_TO_VISEME[v] == VOWEL_TO_VISEME[v.lower()], v
57
+ assert DEVOICED == frozenset("AIUEO")
58
+
59
+ assert VOWEL_TO_VISEME["N"] == "closed" # ん
60
+ assert VOWEL_TO_VISEME["cl"] == "closed" # っ
61
+ assert VOWEL_TO_VISEME["pau"] == "closed" # silence
62
+
63
+ # Exactly 13: an extra key is an invented symbol, a missing key is a frozen mouth.
64
+ assert len(VOWEL_TO_VISEME) == 13, sorted(VOWEL_TO_VISEME)
65
+
66
+ # Devoiced vowels still open the mouth, just less.
67
+ assert viseme_for("u") == ("ou", 1.0)
68
+ assert viseme_for("U") == ("ou", 0.5)
69
+ assert viseme_for("I") == ("ih", 0.5)
70
+ assert viseme_for("N") == ("closed", 0.0)
71
+ assert viseme_for("pau") == ("closed", 0.0)
72
+
73
+ # A new VOICEVOX symbol must fail loudly rather than animate wrongly.
74
+ with pytest.raises(KeyError):
75
+ viseme_for("x")
76
+ with pytest.raises(KeyError):
77
+ VOWEL_TO_VISEME["q"]
78
+
79
+
80
+ @pytest.mark.parametrize(("frames", "expected", "half_up"), BANKERS_VECTORS)
81
+ def test_frame_quantisation_bankers(frames, expected, half_up):
82
+ """AVTR-02: round-half-to-even, matching np.round inside VOICEVOX itself.
83
+
84
+ The VOICEVOX source carries the warning 「NOTE: `round` は偶数丸め。移植時に取扱い注意。」
85
+ Python's built-in round() is banker's, so a Python port matches for free; JavaScript's
86
+ Math.round() is round-half-up and would disagree on every exact-half boundary. That is an
87
+ independent, concrete reason this arithmetic lives in Python.
88
+ """
89
+ assert FRAMERATE == 93.75
90
+ assert FRAMERATE == 24000 / 256
91
+
92
+ seconds = frames / FRAMERATE
93
+ # Fail loudly if a future Python stops round-tripping, rather than passing by luck.
94
+ assert seconds * FRAMERATE == frames, (frames, seconds * FRAMERATE)
95
+
96
+ assert to_frame(seconds) == expected
97
+
98
+ # Provably NOT round-half-up: on the exact-half boundaries the two modes disagree.
99
+ if expected != half_up:
100
+ assert to_frame(seconds) != math.floor(seconds * FRAMERATE + 0.5)
101
+ assert math.floor(seconds * FRAMERATE + 0.5) == half_up