WolfDavid commited on
Commit
122fc80
Β·
1 Parent(s): b0f00f6

test(01-04): capture ground-truth synthesis fixtures for AVTR-02

Browse files

- make_synth_fixtures.py generates three cases and refuses to write a 'long'
fixture without 20+ moras, a devoiced vowel and a pause mora
- Durations and frame counts come from the WAV header, never from summing the query
- Establish the correct frame-quantisation formula empirically: VOICEVOX quantises
each phoneme at speed 1.0 and then divides the frame count by speedScale, rather
than dividing the length first. Verified 24/24 across 6 speeds and 4 sentences;
the length-first form is right only 8/24 and is off by up to 5 frames
- The generator asserts that formula reproduces each fixture's true frame count,
so the fixtures cannot drift away from the engine unnoticed
- Add a regression guard that fires if a voicevox_core bump changes timings
- Memoise synthesis in the test module: it was doing six identical syntheses and
had pushed the quick loop to ~20s against a 15s budget; now ~11s
- Write fixture JSON with explicit LF so regeneration is byte-identical across OSes

docs/VOICEVOX-SETUP.md CHANGED
@@ -74,13 +74,72 @@ institutions are genuinely both involved and RESEARCH conflated them.
74
  Everything else RESEARCH inferred was correct, including the whole `Onnxruntime` β†’ `OpenJtalk` β†’
75
  `Synthesizer` β†’ `VoiceModelFile` construction order and the exact Linux wheel filename.
76
 
77
- ### Bonus: the frame-quantisation algorithm is now empirically verified
 
78
 
79
- RESEARCH's `build_timeline` pipeline (flatten β†’ wrap in pre/post silence β†’ divide by `speedScale`
80
- β†’ quantise each phoneme with `round(sec * 93.75)` using banker's rounding β†’ accumulate) was
81
- replayed against a real 36-mora synthesis. The accumulated timeline total and the true WAV
82
- duration agree to **2.7e-15 s** β€” i.e. exactly, to float representation. Plan 01-06 can implement
83
- that algorithm as written and expect bit-exact agreement.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
  ---
86
 
 
74
  Everything else RESEARCH inferred was correct, including the whole `Onnxruntime` β†’ `OpenJtalk` β†’
75
  `Synthesizer` β†’ `VoiceModelFile` construction order and the exact Linux wheel filename.
76
 
77
+ **6. `speedScale` is applied to the frame count, not to the phoneme length.** This is the most
78
+ consequential correction in this document and it has its own section below.
79
 
80
+ ---
81
+
82
+ ## Frame quantisation β€” read this before writing `visemes.py`
83
+
84
+ **Plan 01-06 depends on this section.** 01-RESEARCH.md's `build_timeline` gets the order wrong,
85
+ and the error is invisible at normal speed, which is exactly what makes it dangerous.
86
+
87
+ RESEARCH's pipeline divides each phoneme length by `speedScale` and *then* quantises:
88
+
89
+ ```python
90
+ frames = round(length / speed_scale * 93.75) # WRONG
91
+ ```
92
+
93
+ VOICEVOX CORE 0.17.0 actually quantises **first**, at speed 1.0, and then divides the resulting
94
+ **frame count** and rounds again:
95
+
96
+ ```python
97
+ frames = round(round(length * 93.75) / speed_scale) # CORRECT
98
+ ```
99
+
100
+ At `speedScale == 1.0` the two are identical, so RESEARCH's version looks verified. They diverge
101
+ everywhere else.
102
+
103
+ Measured: 4 sentences Γ— 6 speed values (1.0, 0.9, 0.75, 0.5, 1.25, 1.5), predicted total frames
104
+ compared against the true frame count of the synthesised WAV (`getnframes() // 256`):
105
+
106
+ | Formula | Correct |
107
+ |---|---|
108
+ | `round(length / speed * 93.75)` (01-RESEARCH.md) | **8 / 24** |
109
+ | `round(round(length * 93.75) / speed)` | **24 / 24** |
110
+
111
+ Worst observed error from the wrong formula: **5 frames β‰ˆ 53 ms** on the long sentence at
112
+ `speedScale = 1.25`. That is well past the Β±1 frame (10.667 ms) tolerance `test_no_drift_long_utterance`
113
+ is specified with, and it is exactly the symptom `01-RESEARCH.md` Pitfall 5 describes β€” "normal
114
+ speed syncs, slow speed drifts" β€” reached from the other direction.
115
+
116
+ Everything else about the pipeline is confirmed and should be implemented as RESEARCH describes:
117
+
118
+ - Flatten each accent phrase's `moras`, then its `pause_mora` if present β€” pause **after**.
119
+ - Wrap the whole sequence in `prePhonemeLength` / `postPhonemeLength` silence moras, and scale
120
+ those by `speedScale` like any other phoneme. They are not exempt.
121
+ - Emit the consonant (mouth closed) before the vowel within a mora.
122
+ - Accumulate the quantised frame counts, never the raw floats. Per-phoneme rounding error is up to
123
+ Β±0.5 frame β‰ˆ Β±5.3 ms, and the long fixture has 61 phonemes.
124
+ - Use banker's rounding. Python's built-in `round()` already is; JavaScript's `Math.round()` is
125
+ round-half-up and would disagree, which is an independent reason the timeline is built in Python
126
+ and the browser only plays it.
127
+ - Steps involving `pauseLength` / `pauseLengthScale` are no-ops β€” those fields do not exist here.
128
+
129
+ The committed fixtures are the proof: `tests/fixtures/make_synth_fixtures.py` asserts the correct
130
+ formula reproduces each fixture's true frame count before it will write anything, so the fixture
131
+ set cannot silently drift away from the engine.
132
+
133
+ | Fixture | speedScale | Frames | Duration (s) |
134
+ |---|---|---|---|
135
+ | `short` | 1.0 | 99 | 1.056 |
136
+ | `long` | 1.0 | 516 | 5.504 |
137
+ | `slow` | 0.75 | 692 | 7.381333333333333 |
138
+
139
+ Note that `slow / long = 1.341085`, **not** exactly `1/0.75 = 1.333333`. Re-quantisation after
140
+ scaling means the realised ratio lands near the requested one, not on it. A test that asserts
141
+ "exactly 1/0.75Γ— longer" against *durations* will fail; assert the timeline matches the WAV
142
+ instead, which is the property that actually matters for lip-sync.
143
 
144
  ---
145
 
tests/fixtures/audio_query_long.json ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "accent_phrases": [
3
+ {
4
+ "accent": 1,
5
+ "is_interrogative": false,
6
+ "moras": [
7
+ {
8
+ "consonant": "ky",
9
+ "consonant_length": 0.1269165426492691,
10
+ "pitch": 5.891244888305664,
11
+ "text": "キョ",
12
+ "vowel": "o",
13
+ "vowel_length": 0.10178578644990921
14
+ },
15
+ {
16
+ "consonant": null,
17
+ "consonant_length": null,
18
+ "pitch": 5.944635391235352,
19
+ "text": "γ‚ͺ",
20
+ "vowel": "o",
21
+ "vowel_length": 0.08867011219263077
22
+ },
23
+ {
24
+ "consonant": "w",
25
+ "consonant_length": 0.06420903652906418,
26
+ "pitch": 5.655149459838867,
27
+ "text": "γƒ―",
28
+ "vowel": "a",
29
+ "vowel_length": 0.13292233645915985
30
+ }
31
+ ],
32
+ "pause_mora": null
33
+ },
34
+ {
35
+ "accent": 1,
36
+ "is_interrogative": false,
37
+ "moras": [
38
+ {
39
+ "consonant": null,
40
+ "consonant_length": null,
41
+ "pitch": 5.756601810455322,
42
+ "text": "γ‚€",
43
+ "vowel": "i",
44
+ "vowel_length": 0.09258951991796494
45
+ },
46
+ {
47
+ "consonant": null,
48
+ "consonant_length": null,
49
+ "pitch": 6.103951930999756,
50
+ "text": "γ‚€",
51
+ "vowel": "i",
52
+ "vowel_length": 0.0974084809422493
53
+ }
54
+ ],
55
+ "pause_mora": null
56
+ },
57
+ {
58
+ "accent": 1,
59
+ "is_interrogative": false,
60
+ "moras": [
61
+ {
62
+ "consonant": "t",
63
+ "consonant_length": 0.0657225176692009,
64
+ "pitch": 6.139893531799316,
65
+ "text": "テ",
66
+ "vowel": "e",
67
+ "vowel_length": 0.1257137954235077
68
+ },
69
+ {
70
+ "consonant": null,
71
+ "consonant_length": null,
72
+ "pitch": 6.0533294677734375,
73
+ "text": "ン",
74
+ "vowel": "N",
75
+ "vowel_length": 0.07083306461572647
76
+ },
77
+ {
78
+ "consonant": "k",
79
+ "consonant_length": 0.05886319279670715,
80
+ "pitch": 5.938107967376709,
81
+ "text": "γ‚­",
82
+ "vowel": "i",
83
+ "vowel_length": 0.059421248733997345
84
+ },
85
+ {
86
+ "consonant": "d",
87
+ "consonant_length": 0.05466592311859131,
88
+ "pitch": 5.8197479248046875,
89
+ "text": "デ",
90
+ "vowel": "e",
91
+ "vowel_length": 0.09957551956176758
92
+ },
93
+ {
94
+ "consonant": "s",
95
+ "consonant_length": 0.03665236383676529,
96
+ "pitch": 0.0,
97
+ "text": "γ‚Ή",
98
+ "vowel": "U",
99
+ "vowel_length": 0.06477490812540054
100
+ },
101
+ {
102
+ "consonant": "k",
103
+ "consonant_length": 0.07497167587280273,
104
+ "pitch": 5.6952314376831055,
105
+ "text": "γ‚«",
106
+ "vowel": "a",
107
+ "vowel_length": 0.09379018098115921
108
+ },
109
+ {
110
+ "consonant": "r",
111
+ "consonant_length": 0.03401697427034378,
112
+ "pitch": 5.5977702140808105,
113
+ "text": "ラ",
114
+ "vowel": "a",
115
+ "vowel_length": 0.14935533702373505
116
+ }
117
+ ],
118
+ "pause_mora": {
119
+ "consonant": null,
120
+ "consonant_length": null,
121
+ "pitch": 0.0,
122
+ "text": "、",
123
+ "vowel": "pau",
124
+ "vowel_length": 0.2590501606464386
125
+ }
126
+ },
127
+ {
128
+ "accent": 5,
129
+ "is_interrogative": false,
130
+ "moras": [
131
+ {
132
+ "consonant": "k",
133
+ "consonant_length": 0.08955883234739304,
134
+ "pitch": 5.693568229675293,
135
+ "text": "γ‚³",
136
+ "vowel": "o",
137
+ "vowel_length": 0.09677395969629288
138
+ },
139
+ {
140
+ "consonant": null,
141
+ "consonant_length": null,
142
+ "pitch": 5.938162803649902,
143
+ "text": "γ‚ͺ",
144
+ "vowel": "o",
145
+ "vowel_length": 0.12056692689657211
146
+ },
147
+ {
148
+ "consonant": null,
149
+ "consonant_length": null,
150
+ "pitch": 6.016718864440918,
151
+ "text": "エ",
152
+ "vowel": "e",
153
+ "vowel_length": 0.147250235080719
154
+ },
155
+ {
156
+ "consonant": null,
157
+ "consonant_length": null,
158
+ "pitch": 6.010623931884766,
159
+ "text": "ン",
160
+ "vowel": "N",
161
+ "vowel_length": 0.08805551379919052
162
+ },
163
+ {
164
+ "consonant": null,
165
+ "consonant_length": null,
166
+ "pitch": 6.007989883422852,
167
+ "text": "γ‚ͺ",
168
+ "vowel": "o",
169
+ "vowel_length": 0.10105019062757492
170
+ }
171
+ ],
172
+ "pause_mora": null
173
+ },
174
+ {
175
+ "accent": 3,
176
+ "is_interrogative": false,
177
+ "moras": [
178
+ {
179
+ "consonant": "s",
180
+ "consonant_length": 0.0925176665186882,
181
+ "pitch": 6.003979206085205,
182
+ "text": "γ‚΅",
183
+ "vowel": "a",
184
+ "vowel_length": 0.1288621425628662
185
+ },
186
+ {
187
+ "consonant": null,
188
+ "consonant_length": null,
189
+ "pitch": 6.026742935180664,
190
+ "text": "ン",
191
+ "vowel": "N",
192
+ "vowel_length": 0.0830387994647026
193
+ },
194
+ {
195
+ "consonant": "p",
196
+ "consonant_length": 0.054095521569252014,
197
+ "pitch": 6.099804878234863,
198
+ "text": "ポ",
199
+ "vowel": "o",
200
+ "vowel_length": 0.1004747673869133
201
+ }
202
+ ],
203
+ "pause_mora": null
204
+ },
205
+ {
206
+ "accent": 4,
207
+ "is_interrogative": false,
208
+ "moras": [
209
+ {
210
+ "consonant": "sh",
211
+ "consonant_length": 0.021739566698670387,
212
+ "pitch": 0.0,
213
+ "text": "γ‚·",
214
+ "vowel": "I",
215
+ "vowel_length": 0.04718482494354248
216
+ },
217
+ {
218
+ "consonant": "t",
219
+ "consonant_length": 0.05912453681230545,
220
+ "pitch": 6.083391189575195,
221
+ "text": "テ",
222
+ "vowel": "e",
223
+ "vowel_length": 0.08641223609447479
224
+ },
225
+ {
226
+ "consonant": "k",
227
+ "consonant_length": 0.05805716663599014,
228
+ "pitch": 6.061047554016113,
229
+ "text": "γ‚«",
230
+ "vowel": "a",
231
+ "vowel_length": 0.0868045762181282
232
+ },
233
+ {
234
+ "consonant": "r",
235
+ "consonant_length": 0.03226054459810257,
236
+ "pitch": 5.876008987426758,
237
+ "text": "ラ",
238
+ "vowel": "a",
239
+ "vowel_length": 0.14383982121944427
240
+ }
241
+ ],
242
+ "pause_mora": {
243
+ "consonant": null,
244
+ "consonant_length": null,
245
+ "pitch": 0.0,
246
+ "text": "、",
247
+ "vowel": "pau",
248
+ "vowel_length": 0.2888171374797821
249
+ }
250
+ },
251
+ {
252
+ "accent": 5,
253
+ "is_interrogative": false,
254
+ "moras": [
255
+ {
256
+ "consonant": "k",
257
+ "consonant_length": 0.0854499563574791,
258
+ "pitch": 5.536592483520508,
259
+ "text": "γ‚«",
260
+ "vowel": "a",
261
+ "vowel_length": 0.12839564681053162
262
+ },
263
+ {
264
+ "consonant": null,
265
+ "consonant_length": null,
266
+ "pitch": 5.821318626403809,
267
+ "text": "γ‚€",
268
+ "vowel": "i",
269
+ "vowel_length": 0.07545183598995209
270
+ },
271
+ {
272
+ "consonant": "m",
273
+ "consonant_length": 0.05912942439317703,
274
+ "pitch": 6.023046493530273,
275
+ "text": "γƒ’",
276
+ "vowel": "o",
277
+ "vowel_length": 0.0851563960313797
278
+ },
279
+ {
280
+ "consonant": "n",
281
+ "consonant_length": 0.05413999408483505,
282
+ "pitch": 6.078396797180176,
283
+ "text": "γƒŽ",
284
+ "vowel": "o",
285
+ "vowel_length": 0.08871601521968842
286
+ },
287
+ {
288
+ "consonant": "n",
289
+ "consonant_length": 0.07090981304645538,
290
+ "pitch": 6.0716552734375,
291
+ "text": "ニ",
292
+ "vowel": "i",
293
+ "vowel_length": 0.0862032100558281
294
+ }
295
+ ],
296
+ "pause_mora": null
297
+ },
298
+ {
299
+ "accent": 3,
300
+ "is_interrogative": false,
301
+ "moras": [
302
+ {
303
+ "consonant": null,
304
+ "consonant_length": null,
305
+ "pitch": 6.005620956420898,
306
+ "text": "γ‚€",
307
+ "vowel": "i",
308
+ "vowel_length": 0.09876994043588638
309
+ },
310
+ {
311
+ "consonant": "k",
312
+ "consonant_length": 0.07177173346281052,
313
+ "pitch": 6.0334930419921875,
314
+ "text": "γ‚­",
315
+ "vowel": "i",
316
+ "vowel_length": 0.057893283665180206
317
+ },
318
+ {
319
+ "consonant": "m",
320
+ "consonant_length": 0.058485276997089386,
321
+ "pitch": 6.074601173400879,
322
+ "text": "γƒž",
323
+ "vowel": "a",
324
+ "vowel_length": 0.09547590464353561
325
+ },
326
+ {
327
+ "consonant": "sh",
328
+ "consonant_length": 0.030789220705628395,
329
+ "pitch": 0.0,
330
+ "text": "γ‚·",
331
+ "vowel": "I",
332
+ "vowel_length": 0.06114967167377472
333
+ },
334
+ {
335
+ "consonant": "t",
336
+ "consonant_length": 0.05993541330099106,
337
+ "pitch": 5.849719524383545,
338
+ "text": "γ‚Ώ",
339
+ "vowel": "a",
340
+ "vowel_length": 0.1888434886932373
341
+ }
342
+ ],
343
+ "pause_mora": null
344
+ }
345
+ ],
346
+ "intonationScale": 1.0,
347
+ "kana": "キョ'γ‚ͺγƒ―/γ‚€'γ‚€/テ'ンキデ_スカラ、コγ‚ͺエンγ‚ͺ'/ァンポ'/_シテカラ'γ€γ‚«γ‚€γƒ’γƒŽγƒ‹'/γ‚€γ‚­γƒž'_γ‚·γ‚Ώ",
348
+ "outputSamplingRate": 24000,
349
+ "outputStereo": false,
350
+ "pitchScale": 0.0,
351
+ "postPhonemeLength": 0.10000000149011612,
352
+ "prePhonemeLength": 0.10000000149011612,
353
+ "speedScale": 1.0,
354
+ "volumeScale": 1.0
355
+ }
tests/fixtures/audio_query_short.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "accent_phrases": [
3
+ {
4
+ "accent": 5,
5
+ "is_interrogative": false,
6
+ "moras": [
7
+ {
8
+ "consonant": "k",
9
+ "consonant_length": 0.09125985950231552,
10
+ "pitch": 5.689502716064453,
11
+ "text": "γ‚³",
12
+ "vowel": "o",
13
+ "vowel_length": 0.1464204043149948
14
+ },
15
+ {
16
+ "consonant": null,
17
+ "consonant_length": null,
18
+ "pitch": 5.868537902832031,
19
+ "text": "ン",
20
+ "vowel": "N",
21
+ "vowel_length": 0.07110694795846939
22
+ },
23
+ {
24
+ "consonant": "n",
25
+ "consonant_length": 0.032809868454933167,
26
+ "pitch": 5.991527080535889,
27
+ "text": "ニ",
28
+ "vowel": "i",
29
+ "vowel_length": 0.09959950298070908
30
+ },
31
+ {
32
+ "consonant": "ch",
33
+ "consonant_length": 0.08417012542486191,
34
+ "pitch": 5.978631973266602,
35
+ "text": "チ",
36
+ "vowel": "i",
37
+ "vowel_length": 0.07654783874750137
38
+ },
39
+ {
40
+ "consonant": "w",
41
+ "consonant_length": 0.07711347192525864,
42
+ "pitch": 6.040231227874756,
43
+ "text": "γƒ―",
44
+ "vowel": "a",
45
+ "vowel_length": 0.1825464963912964
46
+ }
47
+ ],
48
+ "pause_mora": null
49
+ }
50
+ ],
51
+ "intonationScale": 1.0,
52
+ "kana": "コンニチワ'",
53
+ "outputSamplingRate": 24000,
54
+ "outputStereo": false,
55
+ "pitchScale": 0.0,
56
+ "postPhonemeLength": 0.10000000149011612,
57
+ "prePhonemeLength": 0.10000000149011612,
58
+ "speedScale": 1.0,
59
+ "volumeScale": 1.0
60
+ }
tests/fixtures/audio_query_slow.json ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "accent_phrases": [
3
+ {
4
+ "accent": 1,
5
+ "is_interrogative": false,
6
+ "moras": [
7
+ {
8
+ "consonant": "ky",
9
+ "consonant_length": 0.1269165426492691,
10
+ "pitch": 5.891244888305664,
11
+ "text": "キョ",
12
+ "vowel": "o",
13
+ "vowel_length": 0.10178578644990921
14
+ },
15
+ {
16
+ "consonant": null,
17
+ "consonant_length": null,
18
+ "pitch": 5.944635391235352,
19
+ "text": "γ‚ͺ",
20
+ "vowel": "o",
21
+ "vowel_length": 0.08867011219263077
22
+ },
23
+ {
24
+ "consonant": "w",
25
+ "consonant_length": 0.06420903652906418,
26
+ "pitch": 5.655149459838867,
27
+ "text": "γƒ―",
28
+ "vowel": "a",
29
+ "vowel_length": 0.13292233645915985
30
+ }
31
+ ],
32
+ "pause_mora": null
33
+ },
34
+ {
35
+ "accent": 1,
36
+ "is_interrogative": false,
37
+ "moras": [
38
+ {
39
+ "consonant": null,
40
+ "consonant_length": null,
41
+ "pitch": 5.756601810455322,
42
+ "text": "γ‚€",
43
+ "vowel": "i",
44
+ "vowel_length": 0.09258951991796494
45
+ },
46
+ {
47
+ "consonant": null,
48
+ "consonant_length": null,
49
+ "pitch": 6.103951930999756,
50
+ "text": "γ‚€",
51
+ "vowel": "i",
52
+ "vowel_length": 0.0974084809422493
53
+ }
54
+ ],
55
+ "pause_mora": null
56
+ },
57
+ {
58
+ "accent": 1,
59
+ "is_interrogative": false,
60
+ "moras": [
61
+ {
62
+ "consonant": "t",
63
+ "consonant_length": 0.0657225176692009,
64
+ "pitch": 6.139893531799316,
65
+ "text": "テ",
66
+ "vowel": "e",
67
+ "vowel_length": 0.1257137954235077
68
+ },
69
+ {
70
+ "consonant": null,
71
+ "consonant_length": null,
72
+ "pitch": 6.0533294677734375,
73
+ "text": "ン",
74
+ "vowel": "N",
75
+ "vowel_length": 0.07083306461572647
76
+ },
77
+ {
78
+ "consonant": "k",
79
+ "consonant_length": 0.05886319279670715,
80
+ "pitch": 5.938107967376709,
81
+ "text": "γ‚­",
82
+ "vowel": "i",
83
+ "vowel_length": 0.059421248733997345
84
+ },
85
+ {
86
+ "consonant": "d",
87
+ "consonant_length": 0.05466592311859131,
88
+ "pitch": 5.8197479248046875,
89
+ "text": "デ",
90
+ "vowel": "e",
91
+ "vowel_length": 0.09957551956176758
92
+ },
93
+ {
94
+ "consonant": "s",
95
+ "consonant_length": 0.03665236383676529,
96
+ "pitch": 0.0,
97
+ "text": "γ‚Ή",
98
+ "vowel": "U",
99
+ "vowel_length": 0.06477490812540054
100
+ },
101
+ {
102
+ "consonant": "k",
103
+ "consonant_length": 0.07497167587280273,
104
+ "pitch": 5.6952314376831055,
105
+ "text": "γ‚«",
106
+ "vowel": "a",
107
+ "vowel_length": 0.09379018098115921
108
+ },
109
+ {
110
+ "consonant": "r",
111
+ "consonant_length": 0.03401697427034378,
112
+ "pitch": 5.5977702140808105,
113
+ "text": "ラ",
114
+ "vowel": "a",
115
+ "vowel_length": 0.14935533702373505
116
+ }
117
+ ],
118
+ "pause_mora": {
119
+ "consonant": null,
120
+ "consonant_length": null,
121
+ "pitch": 0.0,
122
+ "text": "、",
123
+ "vowel": "pau",
124
+ "vowel_length": 0.2590501606464386
125
+ }
126
+ },
127
+ {
128
+ "accent": 5,
129
+ "is_interrogative": false,
130
+ "moras": [
131
+ {
132
+ "consonant": "k",
133
+ "consonant_length": 0.08955883234739304,
134
+ "pitch": 5.693568229675293,
135
+ "text": "γ‚³",
136
+ "vowel": "o",
137
+ "vowel_length": 0.09677395969629288
138
+ },
139
+ {
140
+ "consonant": null,
141
+ "consonant_length": null,
142
+ "pitch": 5.938162803649902,
143
+ "text": "γ‚ͺ",
144
+ "vowel": "o",
145
+ "vowel_length": 0.12056692689657211
146
+ },
147
+ {
148
+ "consonant": null,
149
+ "consonant_length": null,
150
+ "pitch": 6.016718864440918,
151
+ "text": "エ",
152
+ "vowel": "e",
153
+ "vowel_length": 0.147250235080719
154
+ },
155
+ {
156
+ "consonant": null,
157
+ "consonant_length": null,
158
+ "pitch": 6.010623931884766,
159
+ "text": "ン",
160
+ "vowel": "N",
161
+ "vowel_length": 0.08805551379919052
162
+ },
163
+ {
164
+ "consonant": null,
165
+ "consonant_length": null,
166
+ "pitch": 6.007989883422852,
167
+ "text": "γ‚ͺ",
168
+ "vowel": "o",
169
+ "vowel_length": 0.10105019062757492
170
+ }
171
+ ],
172
+ "pause_mora": null
173
+ },
174
+ {
175
+ "accent": 3,
176
+ "is_interrogative": false,
177
+ "moras": [
178
+ {
179
+ "consonant": "s",
180
+ "consonant_length": 0.0925176665186882,
181
+ "pitch": 6.003979206085205,
182
+ "text": "γ‚΅",
183
+ "vowel": "a",
184
+ "vowel_length": 0.1288621425628662
185
+ },
186
+ {
187
+ "consonant": null,
188
+ "consonant_length": null,
189
+ "pitch": 6.026742935180664,
190
+ "text": "ン",
191
+ "vowel": "N",
192
+ "vowel_length": 0.0830387994647026
193
+ },
194
+ {
195
+ "consonant": "p",
196
+ "consonant_length": 0.054095521569252014,
197
+ "pitch": 6.099804878234863,
198
+ "text": "ポ",
199
+ "vowel": "o",
200
+ "vowel_length": 0.1004747673869133
201
+ }
202
+ ],
203
+ "pause_mora": null
204
+ },
205
+ {
206
+ "accent": 4,
207
+ "is_interrogative": false,
208
+ "moras": [
209
+ {
210
+ "consonant": "sh",
211
+ "consonant_length": 0.021739566698670387,
212
+ "pitch": 0.0,
213
+ "text": "γ‚·",
214
+ "vowel": "I",
215
+ "vowel_length": 0.04718482494354248
216
+ },
217
+ {
218
+ "consonant": "t",
219
+ "consonant_length": 0.05912453681230545,
220
+ "pitch": 6.083391189575195,
221
+ "text": "テ",
222
+ "vowel": "e",
223
+ "vowel_length": 0.08641223609447479
224
+ },
225
+ {
226
+ "consonant": "k",
227
+ "consonant_length": 0.05805716663599014,
228
+ "pitch": 6.061047554016113,
229
+ "text": "γ‚«",
230
+ "vowel": "a",
231
+ "vowel_length": 0.0868045762181282
232
+ },
233
+ {
234
+ "consonant": "r",
235
+ "consonant_length": 0.03226054459810257,
236
+ "pitch": 5.876008987426758,
237
+ "text": "ラ",
238
+ "vowel": "a",
239
+ "vowel_length": 0.14383982121944427
240
+ }
241
+ ],
242
+ "pause_mora": {
243
+ "consonant": null,
244
+ "consonant_length": null,
245
+ "pitch": 0.0,
246
+ "text": "、",
247
+ "vowel": "pau",
248
+ "vowel_length": 0.2888171374797821
249
+ }
250
+ },
251
+ {
252
+ "accent": 5,
253
+ "is_interrogative": false,
254
+ "moras": [
255
+ {
256
+ "consonant": "k",
257
+ "consonant_length": 0.0854499563574791,
258
+ "pitch": 5.536592483520508,
259
+ "text": "γ‚«",
260
+ "vowel": "a",
261
+ "vowel_length": 0.12839564681053162
262
+ },
263
+ {
264
+ "consonant": null,
265
+ "consonant_length": null,
266
+ "pitch": 5.821318626403809,
267
+ "text": "γ‚€",
268
+ "vowel": "i",
269
+ "vowel_length": 0.07545183598995209
270
+ },
271
+ {
272
+ "consonant": "m",
273
+ "consonant_length": 0.05912942439317703,
274
+ "pitch": 6.023046493530273,
275
+ "text": "γƒ’",
276
+ "vowel": "o",
277
+ "vowel_length": 0.0851563960313797
278
+ },
279
+ {
280
+ "consonant": "n",
281
+ "consonant_length": 0.05413999408483505,
282
+ "pitch": 6.078396797180176,
283
+ "text": "γƒŽ",
284
+ "vowel": "o",
285
+ "vowel_length": 0.08871601521968842
286
+ },
287
+ {
288
+ "consonant": "n",
289
+ "consonant_length": 0.07090981304645538,
290
+ "pitch": 6.0716552734375,
291
+ "text": "ニ",
292
+ "vowel": "i",
293
+ "vowel_length": 0.0862032100558281
294
+ }
295
+ ],
296
+ "pause_mora": null
297
+ },
298
+ {
299
+ "accent": 3,
300
+ "is_interrogative": false,
301
+ "moras": [
302
+ {
303
+ "consonant": null,
304
+ "consonant_length": null,
305
+ "pitch": 6.005620956420898,
306
+ "text": "γ‚€",
307
+ "vowel": "i",
308
+ "vowel_length": 0.09876994043588638
309
+ },
310
+ {
311
+ "consonant": "k",
312
+ "consonant_length": 0.07177173346281052,
313
+ "pitch": 6.0334930419921875,
314
+ "text": "γ‚­",
315
+ "vowel": "i",
316
+ "vowel_length": 0.057893283665180206
317
+ },
318
+ {
319
+ "consonant": "m",
320
+ "consonant_length": 0.058485276997089386,
321
+ "pitch": 6.074601173400879,
322
+ "text": "γƒž",
323
+ "vowel": "a",
324
+ "vowel_length": 0.09547590464353561
325
+ },
326
+ {
327
+ "consonant": "sh",
328
+ "consonant_length": 0.030789220705628395,
329
+ "pitch": 0.0,
330
+ "text": "γ‚·",
331
+ "vowel": "I",
332
+ "vowel_length": 0.06114967167377472
333
+ },
334
+ {
335
+ "consonant": "t",
336
+ "consonant_length": 0.05993541330099106,
337
+ "pitch": 5.849719524383545,
338
+ "text": "γ‚Ώ",
339
+ "vowel": "a",
340
+ "vowel_length": 0.1888434886932373
341
+ }
342
+ ],
343
+ "pause_mora": null
344
+ }
345
+ ],
346
+ "intonationScale": 1.0,
347
+ "kana": "キョ'γ‚ͺγƒ―/γ‚€'γ‚€/テ'ンキデ_スカラ、コγ‚ͺエンγ‚ͺ'/ァンポ'/_シテカラ'γ€γ‚«γ‚€γƒ’γƒŽγƒ‹'/γ‚€γ‚­γƒž'_γ‚·γ‚Ώ",
348
+ "outputSamplingRate": 24000,
349
+ "outputStereo": false,
350
+ "pitchScale": 0.0,
351
+ "postPhonemeLength": 0.10000000149011612,
352
+ "prePhonemeLength": 0.10000000149011612,
353
+ "speedScale": 0.75,
354
+ "volumeScale": 1.0
355
+ }
tests/fixtures/make_synth_fixtures.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regenerate the committed VOICEVOX synthesis fixtures.
2
+
3
+ uv run --extra voice python tests/fixtures/make_synth_fixtures.py
4
+
5
+ This is a **maintainer action**, not part of CI. The outputs are the ground truth for every
6
+ AVTR-02 assertion in plan 01-06, so every duration here is read from the WAV header and never
7
+ summed from the query - a summed query is the very thing the no-drift test exists to check.
8
+
9
+ Three cases, chosen deliberately:
10
+
11
+ ===== ======================= ===== ==============================================================
12
+ Case Text Speed Why this text
13
+ ===== ======================= ===== ==============================================================
14
+ short こんにけは 1.0 Contains ``N`` (γ‚“) between vowels - exercises the closed-mouth
15
+ path. Doubles as the push-to-talk fixture for VOIC-02.
16
+ long (see LONG_TEXT) 1.0 です devoices to ``d e s U`` and γ—γŸ to ``sh I t a``, so the
17
+ fixture exercises the uppercase devoiced vowels that a
18
+ lowercase-only lookup silently drops. ~36 moras, long enough
19
+ that float-accumulation drift would be plainly visible.
20
+ slow same as long 0.75 VOIC-03's mechanism; the timeline must be exactly 1/0.75x
21
+ longer, pre/post silence included.
22
+ ===== ======================= ===== ==============================================================
23
+
24
+ The generator refuses to write a ``long`` fixture that does not actually contain a devoiced vowel,
25
+ a pause mora and 20+ moras. A fixture that does not exercise those branches is the wrong fixture,
26
+ and finding that out here is much cheaper than finding it out in plan 01-06.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import datetime as dt
32
+ import json
33
+ import sys
34
+ import wave
35
+ from pathlib import Path
36
+
37
+ FIXTURES = Path(__file__).resolve().parent
38
+ REPO_ROOT = FIXTURES.parents[1]
39
+ sys.path.insert(0, str(REPO_ROOT / "src"))
40
+
41
+ SHORT_TEXT = "こんにけは"
42
+ LONG_TEXT = "今ζ—₯γ―γ„γ„ε€©ζ°—γ§γ™γ‹γ‚‰γ€ε…¬εœ’γ‚’ζ•£ζ­©γ—γ¦γ‹γ‚‰γ€θ²·γ„η‰©γ«θ‘ŒγγΎγ—γŸγ€‚"
43
+
44
+ VOICEVOX_CORE_VERSION = "0.17.0"
45
+ VOICEVOX_VVM_VERSION = "0.17.0"
46
+
47
+ DEVOICED_VOWELS = frozenset("AIUEO")
48
+ MIN_LONG_MORAS = 20
49
+
50
+ #: 24000 Hz / 256 samples. Every phoneme is quantised to a whole number of these.
51
+ FRAMERATE = 93.75
52
+
53
+ CASES = [
54
+ ("short", SHORT_TEXT, 1.0, "speech_ja.wav"),
55
+ ("long", LONG_TEXT, 1.0, "speech_ja_long.wav"),
56
+ ("slow", LONG_TEXT, 0.75, "speech_ja_slow.wav"),
57
+ ]
58
+
59
+
60
+ def flatten_moras(query: dict) -> list[dict]:
61
+ """Every mora in utterance order, each accent phrase's pause mora following its moras."""
62
+ moras: list[dict] = []
63
+ for phrase in query["accent_phrases"]:
64
+ moras.extend(phrase["moras"])
65
+ if phrase["pause_mora"]:
66
+ moras.append(phrase["pause_mora"])
67
+ return moras
68
+
69
+
70
+ def phoneme_lengths(query: dict) -> list[float]:
71
+ """Unscaled phoneme durations in seconds, wrapped in the pre/post silence, in order."""
72
+ silence = {"vowel": "pau", "consonant": None, "consonant_length": None}
73
+ moras = [
74
+ {**silence, "vowel_length": query["prePhonemeLength"]},
75
+ *flatten_moras(query),
76
+ {**silence, "vowel_length": query["postPhonemeLength"]},
77
+ ]
78
+ lengths: list[float] = []
79
+ for mora in moras:
80
+ if mora["consonant"] is not None:
81
+ lengths.append(mora["consonant_length"])
82
+ lengths.append(mora["vowel_length"])
83
+ return lengths
84
+
85
+
86
+ def write_wav(path: Path, wav_bytes: bytes) -> tuple[float, int]:
87
+ """Write the WAV; return its true duration and frame count from the header just written.
88
+
89
+ ``frame_count`` is in VOICEVOX frames (256 samples at 24000 Hz = 93.75 fps), not PCM samples,
90
+ because that is the unit plan 01-06's +/-1 frame no-drift tolerance is expressed in.
91
+ """
92
+ path.write_bytes(wav_bytes)
93
+ with wave.open(str(path), "rb") as handle:
94
+ assert handle.getframerate() == 24000, handle.getframerate()
95
+ assert handle.getnchannels() == 1, handle.getnchannels()
96
+ assert handle.getsampwidth() == 2, handle.getsampwidth()
97
+ samples = handle.getnframes()
98
+ assert samples % 256 == 0, samples
99
+ return samples / float(handle.getframerate()), samples // 256
100
+
101
+
102
+ def main() -> int:
103
+ try:
104
+ import voicevox_core # noqa: F401
105
+ except ImportError:
106
+ print(
107
+ "voicevox_core is not installed, so the fixtures cannot be regenerated.\n"
108
+ "Install it with `uv sync --extra dev --extra voice`; see docs/VOICEVOX-SETUP.md.\n"
109
+ "Regeneration is a maintainer action - CI does not need it.",
110
+ file=sys.stderr,
111
+ )
112
+ return 1
113
+
114
+ from japanese_avatar.voice.tts import SPEAKER_STYLE_ID, synthesize
115
+
116
+ meta: dict = {
117
+ "generated": dt.datetime.now(dt.UTC).date().isoformat(),
118
+ "voicevox_core": VOICEVOX_CORE_VERSION,
119
+ "voicevox_vvm": VOICEVOX_VVM_VERSION,
120
+ "style_id": SPEAKER_STYLE_ID,
121
+ "cases": {},
122
+ }
123
+
124
+ for case, text, speed, wav_name in CASES:
125
+ result = synthesize(text, speed=speed)
126
+ query = result.audio_query
127
+
128
+ query_name = f"audio_query_{case}.json"
129
+ (FIXTURES / query_name).write_text(
130
+ json.dumps(query, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
131
+ encoding="utf-8",
132
+ newline="\n",
133
+ )
134
+
135
+ duration, frame_count = write_wav(FIXTURES / wav_name, result.wav_bytes)
136
+ # The WAV we just wrote must agree with what synthesize() reported, bit for bit.
137
+ assert duration == result.duration, (case, duration, result.duration)
138
+
139
+ # Ground truth for plan 01-06. See the "Frame quantisation" section of
140
+ # docs/VOICEVOX-SETUP.md: quantise at speed 1.0 FIRST, then divide the frame count by
141
+ # speedScale and round again. Dividing the length by speedScale before quantising is a
142
+ # different, wrong answer that only coincides at speed 1.0.
143
+ predicted = sum(
144
+ round(round(length * FRAMERATE) / speed) for length in phoneme_lengths(query)
145
+ )
146
+ assert predicted == frame_count, (case, predicted, frame_count)
147
+
148
+ moras = flatten_moras(query)
149
+ vowels = [m["vowel"] for m in moras]
150
+ pause_moras = sum(1 for p in query["accent_phrases"] if p["pause_mora"])
151
+
152
+ print(
153
+ f"{case:>5}: moras={len(moras):<3} pause_moras={pause_moras} frames={frame_count:<5} "
154
+ f"duration={duration!r} vowels={sorted(set(vowels))}"
155
+ )
156
+
157
+ if case == "long":
158
+ if len(moras) < MIN_LONG_MORAS:
159
+ print(
160
+ f"FAIL: the long fixture has {len(moras)} moras, fewer than "
161
+ f"{MIN_LONG_MORAS}. Pick a longer sentence and record which one.",
162
+ file=sys.stderr,
163
+ )
164
+ return 1
165
+ devoiced = sorted(set(vowels) & DEVOICED_VOWELS)
166
+ if not devoiced:
167
+ print(
168
+ "FAIL: the long fixture contains no devoiced vowel (A/I/U/E/O). Its entire "
169
+ "job is to exercise that branch - pick a different sentence.",
170
+ file=sys.stderr,
171
+ )
172
+ return 1
173
+ if not pause_moras:
174
+ print(
175
+ "FAIL: the long fixture produced no pause_mora. Pick a sentence with a "
176
+ "comma so the pause-ordering branch is covered.",
177
+ file=sys.stderr,
178
+ )
179
+ return 1
180
+ print(f" devoiced vowels present: {devoiced}")
181
+
182
+ meta["cases"][case] = {
183
+ "text": text,
184
+ "speed_scale": speed,
185
+ "query": query_name,
186
+ "wav": wav_name,
187
+ "duration_seconds": duration,
188
+ "frame_count": frame_count,
189
+ "mora_count": len(moras),
190
+ "pause_mora_count": pause_moras,
191
+ "vowel_symbols": vowels,
192
+ }
193
+
194
+ # Not exactly 1/0.75: VOICEVOX re-quantises every phoneme after scaling, so the realised
195
+ # ratio lands within a fraction of a percent of it rather than on it. 2% is the tolerance
196
+ # the fixture contract is written against.
197
+ ratio = meta["cases"]["slow"]["duration_seconds"] / meta["cases"]["long"]["duration_seconds"]
198
+ print(f"slow/long duration ratio = {ratio:.6f} (expected ~{1 / 0.75:.6f})")
199
+ if abs(ratio - 1 / 0.75) >= 0.02:
200
+ print(
201
+ f"FAIL: speedScale=0.75 did not lengthen the audio by ~1/0.75x: {ratio}",
202
+ file=sys.stderr,
203
+ )
204
+ return 1
205
+
206
+ (FIXTURES / "synth_meta.json").write_text(
207
+ json.dumps(meta, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
208
+ encoding="utf-8",
209
+ # Explicit LF: the default translates to CRLF on Windows, which would make the committed
210
+ # fixtures differ byte-for-byte depending on which OS last regenerated them.
211
+ newline="\n",
212
+ )
213
+ print(f"wrote {FIXTURES / 'synth_meta.json'}")
214
+ return 0
215
+
216
+
217
+ if __name__ == "__main__":
218
+ raise SystemExit(main())
tests/fixtures/speech_ja.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:726ee8497999a5aac84567edbc53fb6751529348520bb1643c5a98a548e9f1fc
3
+ size 50732
tests/fixtures/speech_ja_long.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ccbd3f1d0cd7714e1d7ed12518978087be6c251992c9c5c59600742d45291160
3
+ size 264236
tests/fixtures/speech_ja_slow.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c91341fd06952c7e2bab243dffc77cfa4d1db36c636ddc0e74999b309238d544
3
+ size 354348
tests/fixtures/synth_meta.json ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cases": {
3
+ "long": {
4
+ "duration_seconds": 5.504,
5
+ "frame_count": 516,
6
+ "mora_count": 36,
7
+ "pause_mora_count": 2,
8
+ "query": "audio_query_long.json",
9
+ "speed_scale": 1.0,
10
+ "text": "今ζ—₯γ―γ„γ„ε€©ζ°—γ§γ™γ‹γ‚‰γ€ε…¬εœ’γ‚’ζ•£ζ­©γ—γ¦γ‹γ‚‰γ€θ²·γ„η‰©γ«θ‘ŒγγΎγ—γŸγ€‚",
11
+ "vowel_symbols": [
12
+ "o",
13
+ "o",
14
+ "a",
15
+ "i",
16
+ "i",
17
+ "e",
18
+ "N",
19
+ "i",
20
+ "e",
21
+ "U",
22
+ "a",
23
+ "a",
24
+ "pau",
25
+ "o",
26
+ "o",
27
+ "e",
28
+ "N",
29
+ "o",
30
+ "a",
31
+ "N",
32
+ "o",
33
+ "I",
34
+ "e",
35
+ "a",
36
+ "a",
37
+ "pau",
38
+ "a",
39
+ "i",
40
+ "o",
41
+ "o",
42
+ "i",
43
+ "i",
44
+ "i",
45
+ "a",
46
+ "I",
47
+ "a"
48
+ ],
49
+ "wav": "speech_ja_long.wav"
50
+ },
51
+ "short": {
52
+ "duration_seconds": 1.056,
53
+ "frame_count": 99,
54
+ "mora_count": 5,
55
+ "pause_mora_count": 0,
56
+ "query": "audio_query_short.json",
57
+ "speed_scale": 1.0,
58
+ "text": "こんにけは",
59
+ "vowel_symbols": [
60
+ "o",
61
+ "N",
62
+ "i",
63
+ "i",
64
+ "a"
65
+ ],
66
+ "wav": "speech_ja.wav"
67
+ },
68
+ "slow": {
69
+ "duration_seconds": 7.381333333333333,
70
+ "frame_count": 692,
71
+ "mora_count": 36,
72
+ "pause_mora_count": 2,
73
+ "query": "audio_query_slow.json",
74
+ "speed_scale": 0.75,
75
+ "text": "今ζ—₯γ―γ„γ„ε€©ζ°—γ§γ™γ‹γ‚‰γ€ε…¬εœ’γ‚’ζ•£ζ­©γ—γ¦γ‹γ‚‰γ€θ²·γ„η‰©γ«θ‘ŒγγΎγ—γŸγ€‚",
76
+ "vowel_symbols": [
77
+ "o",
78
+ "o",
79
+ "a",
80
+ "i",
81
+ "i",
82
+ "e",
83
+ "N",
84
+ "i",
85
+ "e",
86
+ "U",
87
+ "a",
88
+ "a",
89
+ "pau",
90
+ "o",
91
+ "o",
92
+ "e",
93
+ "N",
94
+ "o",
95
+ "a",
96
+ "N",
97
+ "o",
98
+ "I",
99
+ "e",
100
+ "a",
101
+ "a",
102
+ "pau",
103
+ "a",
104
+ "i",
105
+ "o",
106
+ "o",
107
+ "i",
108
+ "i",
109
+ "i",
110
+ "a",
111
+ "I",
112
+ "a"
113
+ ],
114
+ "wav": "speech_ja_slow.wav"
115
+ }
116
+ },
117
+ "generated": "2026-08-27",
118
+ "style_id": 3,
119
+ "voicevox_core": "0.17.0",
120
+ "voicevox_vvm": "0.17.0"
121
+ }
tests/test_tts_contract.py CHANGED
@@ -23,8 +23,27 @@ LONG_TEXT = "今ζ—₯γ―γ„γ„ε€©ζ°—γ§γ™γ‹γ‚‰γ€ε…¬εœ’γ‚’ζ•£ζ­©γ—γ¦γ‹γ‚‰γ€
23
 
24
 
25
  @pytest.fixture(scope="module")
26
- def short_result():
27
- return synthesize(SHORT_TEXT)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
 
30
  def test_synthesize_returns_nonempty_wav(short_result):
@@ -49,13 +68,13 @@ def test_audio_query_is_plain_json_roundtrippable(short_result):
49
  assert key in query, key
50
 
51
 
52
- def test_moras_carry_timings(short_result):
53
  """Per-mora timings exist, and consonant/consonant_length are genuinely optional.
54
 
55
  The optionality is asserted rather than assumed: plan 01-06's timeline builder must not index
56
  into a key that is absent for every vowel-only mora (γ‚’) and for every pause.
57
  """
58
- result = synthesize(LONG_TEXT)
59
  moras = []
60
  for phrase in result.audio_query["accent_phrases"]:
61
  moras.extend(phrase["moras"])
@@ -74,9 +93,9 @@ def test_moras_carry_timings(short_result):
74
  assert without_consonant, "expected at least one mora with no consonant at all"
75
 
76
 
77
- def test_speed_scale_lengthens_audio():
78
- normal = synthesize(LONG_TEXT, speed=1.0)
79
- slow = synthesize(LONG_TEXT, speed=0.75)
80
  assert slow.duration > normal.duration
81
  ratio = slow.duration / normal.duration
82
  assert abs(ratio - 1 / 0.75) < 0.02 * (1 / 0.75), ratio
@@ -89,6 +108,18 @@ def test_output_sampling_rate_is_24000(short_result):
89
  assert SPEAKER_STYLE_ID == 3
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def test_no_gpu_imports_on_synthesis_path():
93
  modules = sorted(VOICE_PKG.glob("*.py"))
94
  assert modules, f"no modules found under {VOICE_PKG}"
 
23
 
24
 
25
  @pytest.fixture(scope="module")
26
+ def synth():
27
+ """Memoised synthesis, keyed by (text, speed).
28
+
29
+ The engine is deterministic, so synthesising the same pair twice is pure waste - and this
30
+ module would otherwise do it six times over, pushing the quick loop past the 15 s feedback
31
+ budget in ``01-VALIDATION.md``. Three distinct syntheses cover every test below.
32
+ """
33
+ cache: dict[tuple[str, float], object] = {}
34
+
35
+ def _synth(text: str, speed: float = 1.0):
36
+ key = (text, speed)
37
+ if key not in cache:
38
+ cache[key] = synthesize(text, speed=speed)
39
+ return cache[key]
40
+
41
+ return _synth
42
+
43
+
44
+ @pytest.fixture(scope="module")
45
+ def short_result(synth):
46
+ return synth(SHORT_TEXT)
47
 
48
 
49
  def test_synthesize_returns_nonempty_wav(short_result):
 
68
  assert key in query, key
69
 
70
 
71
+ def test_moras_carry_timings(synth):
72
  """Per-mora timings exist, and consonant/consonant_length are genuinely optional.
73
 
74
  The optionality is asserted rather than assumed: plan 01-06's timeline builder must not index
75
  into a key that is absent for every vowel-only mora (γ‚’) and for every pause.
76
  """
77
+ result = synth(LONG_TEXT)
78
  moras = []
79
  for phrase in result.audio_query["accent_phrases"]:
80
  moras.extend(phrase["moras"])
 
93
  assert without_consonant, "expected at least one mora with no consonant at all"
94
 
95
 
96
+ def test_speed_scale_lengthens_audio(synth):
97
+ normal = synth(LONG_TEXT, speed=1.0)
98
+ slow = synth(LONG_TEXT, speed=0.75)
99
  assert slow.duration > normal.duration
100
  ratio = slow.duration / normal.duration
101
  assert abs(ratio - 1 / 0.75) < 0.02 * (1 / 0.75), ratio
 
108
  assert SPEAKER_STYLE_ID == 3
109
 
110
 
111
+ def test_fixtures_match_current_engine(synth_meta, synth):
112
+ """Regression guard: if a voicevox_core bump changes timings, this fails loudly
113
+ rather than test_golden_timeline failing mysteriously in plan 01-06."""
114
+ for case, meta in synth_meta["cases"].items():
115
+ r = synth(meta["text"], speed=meta["speed_scale"])
116
+ assert abs(r.duration - meta["duration_seconds"]) < 0.011, (
117
+ f"{case}: engine now produces {r.duration}s vs recorded {meta['duration_seconds']}s. "
118
+ "Regenerate with tests/fixtures/make_synth_fixtures.py and re-review the "
119
+ "golden timeline."
120
+ )
121
+
122
+
123
  def test_no_gpu_imports_on_synthesis_path():
124
  modules = sorted(VOICE_PKG.glob("*.py"))
125
  assert modules, f"no modules found under {VOICE_PKG}"