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

feat(01-06): add the viseme mapping table and banker's frame quantisation

Browse files

- 13-entry VOWEL_TO_VISEME with uppercase devoiced vowels at reduced weight
- to_frame() uses Python's round(), matching np.round inside VOICEVOX
- frames_for() records the corrected speed-scaling order from docs/VOICEVOX-SETUP.md

src/japanese_avatar/voice/visemes.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build a mouth-shape timeline from a VOICEVOX ``AudioQuery``. Pure function, no I/O, no GPU.
2
+
3
+ This arithmetic lives in Python rather than the browser for one concrete reason: VOICEVOX
4
+ quantises every phoneme with ``np.round``, which is round-half-to-even, and its own source flags
5
+ the hazard - 「NOTE: `round` は偶数丸め。移植時に取扱い注意。」 Python's built-in ``round()`` is
6
+ also round-half-to-even, so a Python port matches for free, whereas JavaScript's ``Math.round()``
7
+ is round-half-up and would disagree on every exact-half boundary. The browser therefore gets a
8
+ finished timeline and plays it dumbly; see ``avatar/lipsync.js``.
9
+
10
+ Nothing here imports ``voicevox_core``. The builder takes the plain ENGINE-schema dict that
11
+ ``tts.audio_query_to_dict`` produces, so it is testable from a committed JSON fixture on a machine
12
+ with no wheel installed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import copy
18
+
19
+ from japanese_avatar.voice.models import VisemeEvent
20
+
21
+ #: 24000 / 256 frames per second. VOICEVOX generates audio by ``np.repeat``-ing each phoneme over
22
+ #: a whole number of these, so every realised phoneme duration is a multiple of 1 / FRAMERATE.
23
+ FRAMERATE = 93.75
24
+
25
+ #: VOICEVOX vowel symbol -> VRM 1.0 expression preset. The symbol set is fixed by
26
+ #: ``voicevox_core/src/engine/acoustic_feature_extractor.rs``; there are exactly 13 entries and an
27
+ #: unknown symbol must raise rather than default, because a silent default animates wrongly.
28
+ #:
29
+ #: The uppercase entries are the devoiced (無声化) vowels. Japanese devoices /i/ and /u/ between
30
+ #: voiceless consonants constantly - です is ``d e s U``, した is ``sh I t a`` - so a
31
+ #: lowercase-only table freezes the mouth on nearly every polite form.
32
+ VOWEL_TO_VISEME = {
33
+ "a": "aa",
34
+ "i": "ih",
35
+ "u": "ou",
36
+ "e": "ee",
37
+ "o": "oh",
38
+ "A": "aa",
39
+ "I": "ih",
40
+ "U": "ou",
41
+ "E": "ee",
42
+ "O": "oh",
43
+ "N": "closed", # ん (moraic nasal)
44
+ "cl": "closed", # っ (geminate stop)
45
+ "pau": "closed", # silence
46
+ }
47
+
48
+ #: The devoiced vowels, which open the mouth at reduced weight rather than not at all.
49
+ DEVOICED = frozenset("AIUEO")
50
+
51
+ _CLOSED = "closed"
52
+
53
+ # A synthetic mora used for prePhonemeLength / postPhonemeLength. VOICEVOX inserts these before
54
+ # the speed step, so they are scaled exactly like any other phoneme - they are not exempt.
55
+ _SILENCE = {"text": "", "vowel": "pau", "consonant": None, "consonant_length": None, "pitch": 0.0}
56
+
57
+
58
+ def to_frame(sec: float) -> int:
59
+ """Quantise seconds to VOICEVOX frames.
60
+
61
+ VOICEVOX's own source flags this: 「NOTE: `round` は偶数丸め。移植時に取扱い注意。」
62
+ ``np.round`` is round-half-to-even (banker's rounding) and so is Python's built-in ``round()``,
63
+ so this matches for free. Do NOT reimplement with a round-half-up primitive.
64
+ """
65
+ return round(sec * FRAMERATE)
66
+
67
+
68
+ def frames_for(sec: float, speed: float = 1.0) -> int:
69
+ """Realised frame count of one phoneme of length ``sec`` at ``speed``.
70
+
71
+ **Order matters, and 01-RESEARCH.md gets it wrong.** CORE 0.17.0 quantises first, at speed
72
+ 1.0, and then divides the resulting *frame count* and rounds again::
73
+
74
+ round(round(sec * 93.75) / speed) # correct
75
+ round(sec / speed * 93.75) # what RESEARCH says - wrong
76
+
77
+ The two agree exactly at ``speed == 1.0``, which is what makes the wrong form look verified.
78
+ Measured over 4 sentences x 6 speed values, the correct form reproduced the true frame count
79
+ of the synthesised WAV 24/24 times and RESEARCH's form 8/24, worst error 5 frames (53 ms) -
80
+ five times the tolerance the no-drift test is written against. See the "Frame quantisation"
81
+ section of ``docs/VOICEVOX-SETUP.md``; ``tests/fixtures/make_synth_fixtures.py`` refuses to
82
+ write a fixture whose predicted frame count disagrees with the engine.
83
+ """
84
+ return round(to_frame(sec) / speed)
85
+
86
+
87
+ def viseme_for(vowel: str) -> tuple[str, float]:
88
+ """-> (viseme, weight). Raises ``KeyError`` on an unknown symbol, by design."""
89
+ viseme = VOWEL_TO_VISEME[vowel]
90
+ if viseme == _CLOSED:
91
+ return viseme, 0.0
92
+ return viseme, 0.5 if vowel in DEVOICED else 1.0
93
+
94
+
95
+ def _flatten_moras(audio_query: dict) -> list[dict]:
96
+ """Every mora in utterance order, each accent phrase's ``pause_mora`` AFTER its moras.
97
+
98
+ Emitting the pause before its phrase is a real and easy inversion; it shifts every mouth shape
99
+ in the phrase by the length of the pause.
100
+ """
101
+ moras: list[dict] = []
102
+ for phrase in audio_query.get("accent_phrases", []):
103
+ moras.extend(phrase.get("moras", []))
104
+ pause = phrase.get("pause_mora")
105
+ if pause:
106
+ moras.append(pause)
107
+ return moras
108
+
109
+
110
+ def _get(audio_query: dict, camel: str, snake: str, default):
111
+ """Read a top-level scalar under either spelling.
112
+
113
+ ``tts.audio_query_to_dict`` emits the ENGINE schema (camelCase scalars), but a caller holding
114
+ a raw ``dataclasses.asdict(query)`` has snake_case. Normalise once, here, rather than
115
+ branching throughout the pipeline.
116
+ """
117
+ for key in (camel, snake):
118
+ if key in audio_query and audio_query[key] is not None:
119
+ return audio_query[key]
120
+ return default
121
+
122
+
123
+ def build_timeline(audio_query: dict) -> list[VisemeEvent]:
124
+ """Turn an ``AudioQuery`` dict into the finished mouth-shape timeline.
125
+
126
+ The five steps, in the order VOICEVOX applies them:
127
+
128
+ 1. Flatten each accent phrase's moras, then its ``pause_mora``.
129
+ 2. Wrap the sequence in ``prePhonemeLength`` / ``postPhonemeLength`` silence moras.
130
+ 3. ``pauseLength`` overrides, then ``pauseLengthScale`` multiplies - both only on ``pau``.
131
+ Neither field exists in ``voicevox_core`` 0.17.0 (they belong to the separate ENGINE HTTP
132
+ product), so this step is a no-op on this stack and is written to tolerate their absence.
133
+ 4. ``speedScale`` divides - see :func:`frames_for` for where in the quantisation it applies.
134
+ 5. Quantise per phoneme and accumulate the quantised frame counts. Accumulating raw floats and
135
+ quantising at the end is the drift bug: up to +/-0.5 frame of error per phoneme, over the
136
+ 61 phonemes of the long fixture.
137
+
138
+ Works on a deep copy - the same query dict is later serialised into the ``AvatarDirective``,
139
+ so mutating the caller's moras would corrupt the payload.
140
+ """
141
+ query = copy.deepcopy(audio_query)
142
+
143
+ speed = float(_get(query, "speedScale", "speed_scale", 1.0)) or 1.0
144
+ pre = float(_get(query, "prePhonemeLength", "pre_phoneme_length", 0.0))
145
+ post = float(_get(query, "postPhonemeLength", "post_phoneme_length", 0.0))
146
+ pause_length = _get(query, "pauseLength", "pause_length", None)
147
+ pause_scale = float(_get(query, "pauseLengthScale", "pause_length_scale", 1.0))
148
+
149
+ moras = [
150
+ {**_SILENCE, "vowel_length": pre},
151
+ *_flatten_moras(query),
152
+ {**_SILENCE, "vowel_length": post},
153
+ ]
154
+
155
+ events: list[VisemeEvent] = []
156
+ frames = 0 # integer accumulator; never a running float
157
+ for mora in moras:
158
+ vowel = mora["vowel"]
159
+ vowel_length = float(mora["vowel_length"])
160
+
161
+ if vowel == "pau":
162
+ if pause_length is not None:
163
+ vowel_length = float(pause_length)
164
+ vowel_length *= pause_scale
165
+
166
+ consonant_length = mora.get("consonant_length")
167
+ if mora.get("consonant") is not None and consonant_length is not None:
168
+ n = frames_for(float(consonant_length), speed)
169
+ events.append(
170
+ VisemeEvent(t=frames / FRAMERATE, dur=n / FRAMERATE, viseme=_CLOSED, weight=0.0)
171
+ )
172
+ frames += n
173
+
174
+ viseme, weight = viseme_for(vowel)
175
+ n = frames_for(vowel_length, speed)
176
+ events.append(
177
+ VisemeEvent(t=frames / FRAMERATE, dur=n / FRAMERATE, viseme=viseme, weight=weight)
178
+ )
179
+ frames += n
180
+
181
+ return events
182
+
183
+
184
+ def timeline_to_dicts(events: list[VisemeEvent]) -> list[dict]:
185
+ """JSON-transport form: ``{"t", "dur", "viseme", "weight"}`` with times to 6 decimals.
186
+
187
+ 6 decimals is sub-microsecond, far below the ~10.7 ms frame, so it costs nothing visually and
188
+ keeps the directive compact. ``tests/test_visemes.py`` pins that claim.
189
+ """
190
+ return [
191
+ {
192
+ "t": round(event.t, 6),
193
+ "dur": round(event.dur, 6),
194
+ "viseme": event.viseme,
195
+ "weight": event.weight,
196
+ }
197
+ for event in events
198
+ ]
tests/test_visemes.py CHANGED
@@ -5,12 +5,12 @@ failure modes are all silent. A wrong rounding mode, a lowercase-only vowel tabl
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
@@ -54,7 +54,7 @@ def test_vowel_mapping():
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" # っ
 
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 the VOICEVOX wheel - the builder is a pure function over plain
13
+ dicts, so the quick loop runs on a machine that has never installed it.
14
  """
15
 
16
  from __future__ import annotations
 
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 frozenset("AIUEO") == DEVOICED
58
 
59
  assert VOWEL_TO_VISEME["N"] == "closed" # ん
60
  assert VOWEL_TO_VISEME["cl"] == "closed" # っ