Ethscriptions commited on
Commit
c67f009
·
verified ·
1 Parent(s): 0682e3f

gemini: 双声旁白、波形对齐字幕、时间轴(step 2)

Browse files
.gitattributes CHANGED
@@ -62,3 +62,13 @@ taurus/covers/cover-channels-3x4.jpg filter=lfs diff=lfs merge=lfs -text
62
  taurus/covers/cover-bili-4x3.jpg filter=lfs diff=lfs merge=lfs -text
63
  taurus/covers/cover-douyin-4x3.jpg filter=lfs diff=lfs merge=lfs -text
64
  taurus/covers/cover-douyin-3x4.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
62
  taurus/covers/cover-bili-4x3.jpg filter=lfs diff=lfs merge=lfs -text
63
  taurus/covers/cover-douyin-4x3.jpg filter=lfs diff=lfs merge=lfs -text
64
  taurus/covers/cover-douyin-3x4.jpg filter=lfs diff=lfs merge=lfs -text
65
+ gemini/audio/n04.mp3 filter=lfs diff=lfs merge=lfs -text
66
+ gemini/audio/n08.mp3 filter=lfs diff=lfs merge=lfs -text
67
+ gemini/audio/n06.mp3 filter=lfs diff=lfs merge=lfs -text
68
+ gemini/audio/n02.mp3 filter=lfs diff=lfs merge=lfs -text
69
+ gemini/audio/n05.mp3 filter=lfs diff=lfs merge=lfs -text
70
+ gemini/audio/n09.mp3 filter=lfs diff=lfs merge=lfs -text
71
+ gemini/audio/n01.mp3 filter=lfs diff=lfs merge=lfs -text
72
+ gemini/audio/n10.mp3 filter=lfs diff=lfs merge=lfs -text
73
+ gemini/audio/n07.mp3 filter=lfs diff=lfs merge=lfs -text
74
+ gemini/audio/n03.mp3 filter=lfs diff=lfs merge=lfs -text
gemini/README.md CHANGED
@@ -9,7 +9,7 @@
9
  | # | 阶段 | 状态 |
10
  |---|---|---|
11
  | 1 | 计划 / 大纲 / 步骤(本文件) | ✅ 完成 |
12
- | 2 | 旁白定稿(10 幕)+ **男女双声交替 TTS**(晓晓 × 云希,象征双生)+ 波形对齐字幕 + 时间轴 | ⏳ |
13
  | 3 | 画面引擎:极光虹彩天幕 + 镜像万花筒粒子 + 星图投影;10 个场景(横/竖屏) | ⏳ |
14
  | 4 | 双星荣誉徽章:两半镜像飞入合璧、3D 旋转、镭射刻名、双色绶带、单独导出 PNG | ⏳ |
15
  | 5 | 灵动配乐(A 大调 · 竖琴 / 钢片琴 / 拨弦 / 长笛对答卡农)+ 音效,预渲染并混音 | ⏳ |
 
9
  | # | 阶段 | 状态 |
10
  |---|---|---|
11
  | 1 | 计划 / 大纲 / 步骤(本文件) | ✅ 完成 |
12
+ | 2 | 旁白定稿(10 幕)+ **男女双声交替 TTS**(晓晓 × 云希,象征双生)+ 波形对齐字幕 + 时间轴 | ✅ 完成(89 句,总长 4:41;字幕起点统一提前 60 ms,有声帧 100% 覆盖) |
13
  | 3 | 画面引擎:极光虹彩天幕 + 镜像万花筒粒子 + 星图投影;10 个场景(横/竖屏) | ⏳ |
14
  | 4 | 双星荣誉徽章:两半镜像飞入合璧、3D 旋转、镭射刻名、双色绶带、单独导出 PNG | ⏳ |
15
  | 5 | 灵动配乐(A 大调 · 竖琴 / 钢片琴 / 拨弦 / 长笛对答卡农)+ 音效,预渲染并混音 | ⏳ |
gemini/audio/n01.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a0e8ac33ec3853cfc82e49d1b3b140f761c1af2f138f6801dcd49c0d0082d48e
3
+ size 265868
gemini/audio/n02.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a63004f10efcf1a15b85485a72463552bf7d0583a15ece47e77cd924e69c0dd6
3
+ size 331244
gemini/audio/n03.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7b71a51e34b066736f29106b22764d7a59724b73ef5d6affba2383ddda573e8a
3
+ size 355724
gemini/audio/n04.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8667b3ad88104e67b2f85ab5e09ba172b37019f93467ba12e88d828a943758f1
3
+ size 466316
gemini/audio/n05.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:09cbc0f42e4ba6e0bddd65b353b49a875aea45100445169e68b1082cdda89bd1
3
+ size 279116
gemini/audio/n06.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f5ac1073b5786b5975307b0bc137bc74e0a9223bd2af6ef775c260c3dce14c25
3
+ size 351404
gemini/audio/n07.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:de3fcd97543f18cbec5ef9a4633a8e1a74fa713f649adf603f92a58647b29846
3
+ size 250028
gemini/audio/n08.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fc3a3e1e5aaebff38d3dba5319783d0b051dd8317a98ca2b6d83f2d95b939eea
3
+ size 343340
gemini/audio/n09.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:155b2ec66b31faf6fd8c8894a4c11aaa3b638ba30f711a006f1f4005caa33869
3
+ size 248588
gemini/audio/n10.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:30f7457402b53a6ebc35cdadde85ffe6f531015ffd7d9d6d2e17f9f9f7cea61e
3
+ size 162764
gemini/engine/timeline.js ADDED
@@ -0,0 +1,684 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 由 tools/narrate.py 生成、tools/align.mjs 校准:双声旁白时间轴与字幕
2
+ export const TIMELINE = {
3
+ "duration": 280.852,
4
+ "voices": {
5
+ "f": "zh-CN-XiaoxiaoNeural",
6
+ "m": "zh-CN-YunxiNeural"
7
+ },
8
+ "acts": [
9
+ {
10
+ "id": "twin",
11
+ "title": "序章 · 镜中的双星",
12
+ "tone": "dawn",
13
+ "start": 0,
14
+ "end": 25.453,
15
+ "voice": {
16
+ "file": "audio/n01.mp3",
17
+ "at": 2.4,
18
+ "dur": 21.753
19
+ },
20
+ "subs": [
21
+ {
22
+ "t0": 2.38,
23
+ "t1": 5.52,
24
+ "text": "五月的尾巴,夏天刚刚推开门",
25
+ "who": "f"
26
+ },
27
+ {
28
+ "t0": 5.54,
29
+ "t1": 9.04,
30
+ "text": "夜空里,有两颗星总是肩并着肩",
31
+ "who": "m"
32
+ },
33
+ {
34
+ "t0": 9.06,
35
+ "t1": 10.68,
36
+ "text": "一颗明亮温暖",
37
+ "who": "f"
38
+ },
39
+ {
40
+ "t0": 10.7,
41
+ "t1": 12.28,
42
+ "text": "一颗清澈闪烁",
43
+ "who": "m"
44
+ },
45
+ {
46
+ "t0": 12.3,
47
+ "t1": 14.44,
48
+ "text": "它们像一面镜子的两边",
49
+ "who": "f"
50
+ },
51
+ {
52
+ "t0": 14.46,
53
+ "t1": 16.71,
54
+ "text": "又像同一个灵魂的两种声音",
55
+ "who": "m"
56
+ },
57
+ {
58
+ "t0": 16.73,
59
+ "t1": 19.62,
60
+ "text": "这就是黄道第三宫,双子座",
61
+ "who": "f"
62
+ },
63
+ {
64
+ "t0": 19.64,
65
+ "t1": 22.35,
66
+ "text": "5 月 21 日到 6 月 21 日出生的你",
67
+ "who": "m"
68
+ },
69
+ {
70
+ "t0": 22.37,
71
+ "t1": 24.23,
72
+ "text": "欢迎回到自己的星空",
73
+ "who": "f"
74
+ }
75
+ ]
76
+ },
77
+ {
78
+ "id": "locate",
79
+ "title": "找到他们",
80
+ "tone": "night",
81
+ "start": 25.453,
82
+ "end": 54.949,
83
+ "voice": {
84
+ "file": "audio/n02.mp3",
85
+ "at": 26.453,
86
+ "dur": 27.196
87
+ },
88
+ "subs": [
89
+ {
90
+ "t0": 26.433,
91
+ "t1": 29.773,
92
+ "text": "冬天和早春的夜晚,先找到猎户座",
93
+ "who": "m"
94
+ },
95
+ {
96
+ "t0": 29.793,
97
+ "t1": 32.473,
98
+ "text": "从猎户脚下的参宿七出发",
99
+ "who": "f"
100
+ },
101
+ {
102
+ "t0": 32.493,
103
+ "t1": 34.843,
104
+ "text": "穿过肩头那颗橙红的参宿四",
105
+ "who": "m"
106
+ },
107
+ {
108
+ "t0": 34.863,
109
+ "t1": 36.923,
110
+ "text": "一直向东北方延伸",
111
+ "who": "f"
112
+ },
113
+ {
114
+ "t0": 36.943,
115
+ "t1": 39.193,
116
+ "text": "你会看到两颗相距不远的亮星",
117
+ "who": "m"
118
+ },
119
+ {
120
+ "t0": 39.523,
121
+ "t1": 42.553,
122
+ "text": "西边那颗是北河二,卡斯托尔",
123
+ "who": "f"
124
+ },
125
+ {
126
+ "t0": 42.573,
127
+ "t1": 46.483,
128
+ "text": "东边更亮的那颗,是北河三,波吕克斯",
129
+ "who": "m"
130
+ },
131
+ {
132
+ "t0": 46.503,
133
+ "t1": 50.273,
134
+ "text": "从两颗星向下,两串星星并排伸展",
135
+ "who": "f"
136
+ },
137
+ {
138
+ "t0": 50.293,
139
+ "t1": 53.623,
140
+ "text": "就像一对兄弟,手牵着手,站在银河边上",
141
+ "who": "m"
142
+ }
143
+ ]
144
+ },
145
+ {
146
+ "id": "pair",
147
+ "title": "两颗星的秘密",
148
+ "tone": "violet",
149
+ "start": 54.949,
150
+ "end": 86.486,
151
+ "voice": {
152
+ "file": "audio/n03.mp3",
153
+ "at": 55.949,
154
+ "dur": 29.237
155
+ },
156
+ "subs": [
157
+ {
158
+ "t0": 55.949,
159
+ "t1": 58.679,
160
+ "text": "有意思的是,排名第一的北河二",
161
+ "who": "f"
162
+ },
163
+ {
164
+ "t0": 58.999,
165
+ "t1": 61.029,
166
+ "text": "其实没有北河三亮",
167
+ "who": "m"
168
+ },
169
+ {
170
+ "t0": 61.049,
171
+ "t1": 63.759,
172
+ "text": "北河三是一颗橙色的巨星",
173
+ "who": "f"
174
+ },
175
+ {
176
+ "t0": 63.779,
177
+ "t1": 65.299,
178
+ "text": "离我们大约34 光年",
179
+ "who": "m"
180
+ },
181
+ {
182
+ "t0": 65.629,
183
+ "t1": 71.019,
184
+ "text": "2006 年,天文学家确认,它身边有一颗行星在转动",
185
+ "who": "f"
186
+ },
187
+ {
188
+ "t0": 71.039,
189
+ "t1": 72.319,
190
+ "text": "而北河二更神奇",
191
+ "who": "m"
192
+ },
193
+ {
194
+ "t0": 72.749,
195
+ "t1": 74.349,
196
+ "text": "它看起来是一颗星",
197
+ "who": "f"
198
+ },
199
+ {
200
+ "t0": 74.659,
201
+ "t1": 76.859,
202
+ "text": "其实是六颗恒星组成的大家庭",
203
+ "who": "m"
204
+ },
205
+ {
206
+ "t0": 77.269,
207
+ "t1": 79.339,
208
+ "text": "三对双星彼此环绕",
209
+ "who": "f"
210
+ },
211
+ {
212
+ "t0": 79.359,
213
+ "t1": 81.469,
214
+ "text": "一起跳着一支很慢很慢的舞",
215
+ "who": "m"
216
+ },
217
+ {
218
+ "t0": 81.489,
219
+ "t1": 85.259,
220
+ "text": "你看,双子座连星星本身,都喜欢热闹",
221
+ "who": "f"
222
+ }
223
+ ]
224
+ },
225
+ {
226
+ "id": "myth",
227
+ "title": "卡斯托尔与波吕克斯",
228
+ "tone": "amber",
229
+ "start": 86.486,
230
+ "end": 127.24,
231
+ "voice": {
232
+ "file": "audio/n04.mp3",
233
+ "at": 87.486,
234
+ "dur": 38.453
235
+ },
236
+ "subs": [
237
+ {
238
+ "t0": 87.486,
239
+ "t1": 91.906,
240
+ "text": "古希腊传说里,卡斯托尔和波吕克斯是一对孪生兄弟",
241
+ "who": "m"
242
+ },
243
+ {
244
+ "t0": 91.926,
245
+ "t1": 94.996,
246
+ "text": "一个擅长驯马,一个擅长拳击",
247
+ "who": "f"
248
+ },
249
+ {
250
+ "t0": 95.016,
251
+ "t1": 98.636,
252
+ "text": "他们一起航海,一起冒险,从不分开",
253
+ "who": "m"
254
+ },
255
+ {
256
+ "t0": 98.656,
257
+ "t1": 100.826,
258
+ "text": "可是卡斯托尔是凡人",
259
+ "who": "f"
260
+ },
261
+ {
262
+ "t0": 100.846,
263
+ "t1": 103.316,
264
+ "text": "波吕克斯却拥有不死之身",
265
+ "who": "m"
266
+ },
267
+ {
268
+ "t0": 103.336,
269
+ "t1": 106.126,
270
+ "text": "当卡斯托尔在战斗中倒下",
271
+ "who": "f"
272
+ },
273
+ {
274
+ "t0": 106.146,
275
+ "t1": 108.156,
276
+ "text": "波吕克斯请求宙斯",
277
+ "who": "m"
278
+ },
279
+ {
280
+ "t0": 108.176,
281
+ "t1": 111.086,
282
+ "text": "把自己的永生,分一半给兄弟",
283
+ "who": "f"
284
+ },
285
+ {
286
+ "t0": 111.106,
287
+ "t1": 115.736,
288
+ "text": "从此,他们一天在天上,一天在冥界,永远在一起",
289
+ "who": "m"
290
+ },
291
+ {
292
+ "t0": 116.266,
293
+ "t1": 120.046,
294
+ "text": "宙斯被这份情谊打动,把他们升上星空",
295
+ "who": "f"
296
+ },
297
+ {
298
+ "t0": 120.066,
299
+ "t1": 123.606,
300
+ "text": "后来的水手们相信,看见桅杆上的蓝色火光",
301
+ "who": "m"
302
+ },
303
+ {
304
+ "t0": 123.916,
305
+ "t1": 126.006,
306
+ "text": "就是双子在为他们护航",
307
+ "who": "f"
308
+ }
309
+ ]
310
+ },
311
+ {
312
+ "id": "china",
313
+ "title": "北河与井宿",
314
+ "tone": "ink",
315
+ "start": 127.24,
316
+ "end": 152.385,
317
+ "voice": {
318
+ "file": "audio/n05.mp3",
319
+ "at": 128.24,
320
+ "dur": 22.846
321
+ },
322
+ "subs": [
323
+ {
324
+ "t0": 128.26,
325
+ "t1": 132.3,
326
+ "text": "在中国古代的星空里,他们有一个好听的名字",
327
+ "who": "f"
328
+ },
329
+ {
330
+ "t0": 132.32,
331
+ "t1": 133.16,
332
+ "text": "北河",
333
+ "who": "m"
334
+ },
335
+ {
336
+ "t0": 133.18,
337
+ "t1": 136.82,
338
+ "text": "北河二,北河三,守在银河的北岸",
339
+ "who": "f"
340
+ },
341
+ {
342
+ "t0": 136.84,
343
+ "t1": 138.66,
344
+ "text": "它们属于二十八宿中的井宿",
345
+ "who": "m"
346
+ },
347
+ {
348
+ "t0": 138.96,
349
+ "t1": 142.24,
350
+ "text": "井宿,是南方朱雀七宿的第一宿",
351
+ "who": "f"
352
+ },
353
+ {
354
+ "t0": 142.26,
355
+ "t1": 145.51,
356
+ "text": "古人看着它,观测天象,校准历法",
357
+ "who": "m"
358
+ },
359
+ {
360
+ "t0": 145.89,
361
+ "t1": 149.67,
362
+ "text": "东方和西方,看见的是同一片星空",
363
+ "who": "f"
364
+ },
365
+ {
366
+ "t0": 149.69,
367
+ "t1": 151.1,
368
+ "text": "却讲出了不同的故事",
369
+ "who": "m"
370
+ }
371
+ ]
372
+ },
373
+ {
374
+ "id": "discover",
375
+ "title": "两颗行星的发现",
376
+ "tone": "deep",
377
+ "start": 152.385,
378
+ "end": 183.553,
379
+ "voice": {
380
+ "file": "audio/n06.mp3",
381
+ "at": 153.385,
382
+ "dur": 28.868
383
+ },
384
+ "subs": [
385
+ {
386
+ "t0": 153.465,
387
+ "t1": 155.635,
388
+ "text": "双子座还有一个了不起的身份",
389
+ "who": "m"
390
+ },
391
+ {
392
+ "t0": 155.655,
393
+ "t1": 156.865,
394
+ "text": "发现之地",
395
+ "who": "f"
396
+ },
397
+ {
398
+ "t0": 156.885,
399
+ "t1": 161.115,
400
+ "text": "1781 年,英国天文学家威廉·赫歇尔",
401
+ "who": "m"
402
+ },
403
+ {
404
+ "t0": 161.525,
405
+ "t1": 165.075,
406
+ "text": "在双子座里,看见一个缓缓移动的光点",
407
+ "who": "f"
408
+ },
409
+ {
410
+ "t0": 165.095,
411
+ "t1": 168.935,
412
+ "text": "那是天王星,人类用望远镜发现的第一颗行星",
413
+ "who": "m"
414
+ },
415
+ {
416
+ "t0": 168.955,
417
+ "t1": 172.955,
418
+ "text": "1930 年,美国青年克莱德·汤博",
419
+ "who": "f"
420
+ },
421
+ {
422
+ "t0": 172.975,
423
+ "t1": 175.155,
424
+ "text": "一张一张地比对星空底片",
425
+ "who": "m"
426
+ },
427
+ {
428
+ "t0": 175.175,
429
+ "t1": 178.925,
430
+ "text": "他在双子座的星星之间,找到了冥王星",
431
+ "who": "f"
432
+ },
433
+ {
434
+ "t0": 178.945,
435
+ "t1": 182.315,
436
+ "text": "好奇心,在这里两次改写了太阳系的地图",
437
+ "who": "m"
438
+ }
439
+ ]
440
+ },
441
+ {
442
+ "id": "meteor",
443
+ "title": "双子座流星雨",
444
+ "tone": "snow",
445
+ "start": 183.553,
446
+ "end": 206.286,
447
+ "voice": {
448
+ "file": "audio/n07.mp3",
449
+ "at": 184.553,
450
+ "dur": 20.432
451
+ },
452
+ "subs": [
453
+ {
454
+ "t0": 184.533,
455
+ "t1": 188.893,
456
+ "text": "每年十二月中旬,双子座还会送出一份礼物",
457
+ "who": "f"
458
+ },
459
+ {
460
+ "t0": 188.913,
461
+ "t1": 190.433,
462
+ "text": "双子座流星雨",
463
+ "who": "m"
464
+ },
465
+ {
466
+ "t0": 190.453,
467
+ "t1": 193.593,
468
+ "text": "它是一年中最壮观的流星雨之一",
469
+ "who": "f"
470
+ },
471
+ {
472
+ "t0": 193.613,
473
+ "t1": 196.963,
474
+ "text": "高峰时,每小时可以看到100 多颗",
475
+ "who": "m"
476
+ },
477
+ {
478
+ "t0": 196.983,
479
+ "t1": 198.873,
480
+ "text": "它的源头不是彗星",
481
+ "who": "f"
482
+ },
483
+ {
484
+ "t0": 198.893,
485
+ "t1": 201.053,
486
+ "text": "而是一颗叫法厄同的小行星",
487
+ "who": "m"
488
+ },
489
+ {
490
+ "t0": 201.073,
491
+ "t1": 202.883,
492
+ "text": "冬夜里抬头许愿吧",
493
+ "who": "f"
494
+ },
495
+ {
496
+ "t0": 203.193,
497
+ "t1": 205.003,
498
+ "text": "星星会从双子的方向飞来",
499
+ "who": "m"
500
+ }
501
+ ]
502
+ },
503
+ {
504
+ "id": "sign",
505
+ "title": "风象 · 水星 · ♊",
506
+ "tone": "aurora",
507
+ "start": 206.286,
508
+ "end": 236.789,
509
+ "voice": {
510
+ "file": "audio/n08.mp3",
511
+ "at": 207.286,
512
+ "dur": 28.204
513
+ },
514
+ "subs": [
515
+ {
516
+ "t0": 207.276,
517
+ "t1": 210.396,
518
+ "text": "双子座的符号,像罗马数字二",
519
+ "who": "f"
520
+ },
521
+ {
522
+ "t0": 210.416,
523
+ "t1": 213.446,
524
+ "text": "两根柱子,被上下两道横梁连在一起",
525
+ "who": "m"
526
+ },
527
+ {
528
+ "t0": 213.896,
529
+ "t1": 215.736,
530
+ "text": "它属于风象星座",
531
+ "who": "f"
532
+ },
533
+ {
534
+ "t0": 215.756,
535
+ "t1": 217.346,
536
+ "text": "守护星是水星",
537
+ "who": "m"
538
+ },
539
+ {
540
+ "t0": 217.366,
541
+ "t1": 221.786,
542
+ "text": "水星是众神的信使,跑得最快,消息最灵",
543
+ "who": "f"
544
+ },
545
+ {
546
+ "t0": 221.806,
547
+ "t1": 226.006,
548
+ "text": "所以人们说,双子座好奇,聪明,反应快",
549
+ "who": "m"
550
+ },
551
+ {
552
+ "t0": 226.026,
553
+ "t1": 228.056,
554
+ "text": "会表达,爱学习",
555
+ "who": "f"
556
+ },
557
+ {
558
+ "t0": 228.076,
559
+ "t1": 229.866,
560
+ "text": "总能把陌生人变成朋友",
561
+ "who": "m"
562
+ },
563
+ {
564
+ "t0": 229.886,
565
+ "t1": 232.166,
566
+ "text": "有人说双子座善变",
567
+ "who": "f"
568
+ },
569
+ {
570
+ "t0": 232.186,
571
+ "t1": 235.436,
572
+ "text": "其实,那是一颗心里,装着两倍的世界",
573
+ "who": "m"
574
+ }
575
+ ]
576
+ },
577
+ {
578
+ "id": "medal",
579
+ "title": "双星荣誉徽章",
580
+ "tone": "royal",
581
+ "start": 236.789,
582
+ "end": 259.402,
583
+ "voice": {
584
+ "file": "audio/n09.mp3",
585
+ "at": 237.789,
586
+ "dur": 20.313
587
+ },
588
+ "subs": [
589
+ {
590
+ "t0": 237.819,
591
+ "t1": 240.119,
592
+ "text": "所以,如果你是双子座",
593
+ "who": "m"
594
+ },
595
+ {
596
+ "t0": 240.139,
597
+ "t1": 243.039,
598
+ "text": "这枚双星荣誉徽章,属于你",
599
+ "who": "f"
600
+ },
601
+ {
602
+ "t0": 243.059,
603
+ "t1": 244.339,
604
+ "text": "一半是北河二的银",
605
+ "who": "m"
606
+ },
607
+ {
608
+ "t0": 244.679,
609
+ "t1": 246.709,
610
+ "text": "一半是北河三的金",
611
+ "who": "f"
612
+ },
613
+ {
614
+ "t0": 246.729,
615
+ "t1": 248.749,
616
+ "text": "上面刻着你的名字",
617
+ "who": "m"
618
+ },
619
+ {
620
+ "t0": 248.769,
621
+ "t1": 252.729,
622
+ "text": "和你最闪亮的天赋,好奇,灵动,真诚",
623
+ "who": "f"
624
+ },
625
+ {
626
+ "t0": 253.049,
627
+ "t1": 254.979,
628
+ "text": "双子座,星空的信使",
629
+ "who": "m"
630
+ },
631
+ {
632
+ "t0": 255.289,
633
+ "t1": 258.179,
634
+ "text": "世界因为你的好奇,变得更有趣",
635
+ "who": "f"
636
+ }
637
+ ]
638
+ },
639
+ {
640
+ "id": "coda",
641
+ "title": "尾声 · 两束光",
642
+ "tone": "dusk",
643
+ "start": 259.402,
644
+ "end": 280.852,
645
+ "voice": {
646
+ "file": "audio/n10.mp3",
647
+ "at": 260.402,
648
+ "dur": 13.15
649
+ },
650
+ "subs": [
651
+ {
652
+ "t0": 260.482,
653
+ "t1": 262.522,
654
+ "text": "下一次,夏天的风吹过来",
655
+ "who": "m"
656
+ },
657
+ {
658
+ "t0": 262.872,
659
+ "t1": 265.862,
660
+ "text": "抬头看看那两颗并肩的星星",
661
+ "who": "f"
662
+ },
663
+ {
664
+ "t0": 265.882,
665
+ "t1": 267.272,
666
+ "text": "你从来不是一个人",
667
+ "who": "m"
668
+ },
669
+ {
670
+ "t0": 267.292,
671
+ "t1": 270.272,
672
+ "text": "你是两束光,也是一整片星空",
673
+ "who": "f"
674
+ },
675
+ {
676
+ "t0": 270.292,
677
+ "t1": 273.602,
678
+ "text": "双子座,保持好奇,闪闪发光",
679
+ "who": "fm"
680
+ }
681
+ ]
682
+ }
683
+ ]
684
+ };
gemini/tools/align.mjs ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ♊ 字幕 ↔ 旁白波形对齐(全新编写)。node gemini/tools/align.mjs (环境变量 FF = ffmpeg)
2
+ // 起点:吸附到真实发声点前 LEAD 秒;终点:到下一句开口前、本句最后一个有声帧之后 TAIL 秒
3
+ import fs from 'fs'; import path from 'path'; import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url';
4
+ const DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), FILE = path.join(DIR, 'engine/timeline.js');
5
+ const src = fs.readFileSync(FILE, 'utf8'), TL = JSON.parse(src.slice(src.indexOf('{'), src.lastIndexOf('}') + 1));
6
+ const SR = 8000, FR = 100, LEAD = .06, TAIL = .15, stats = [];
7
+ for (const A of TL.acts) {
8
+ const b = execFileSync(process.env.FF || 'ffmpeg', ['-v', 'error', '-i', path.join(DIR, A.voice.file), '-ac', '1', '-ar', '' + SR, '-f', 'f32le', '-'], { maxBuffer: 1 << 26 });
9
+ const x = new Float32Array(b.buffer, b.byteOffset, b.length / 4), hop = SR / FR, e = [];
10
+ for (let i = 0; i < x.length; i += hop) { let s = 0; for (let j = i; j < i + hop && j < x.length; j++) s += x[j] * x[j]; e.push(Math.sqrt(s / hop)); }
11
+ const th = Math.max(.01, Math.max(...e) * .055), loud = f => e[f] > th;
12
+ const onset = f0 => { let best = null; for (let d = -40; d <= 40; d++) { const f = f0 + d; if (f >= 0 && loud(f) && [1, 2, 3, 4, 5].every(k => f - k < 0 || !loud(f - k)) && (best === null || Math.abs(d) < Math.abs(best))) best = d; } return best === null ? f0 : f0 + best; };
13
+ const on = A.subs.map((s, i) => i === 0 ? e.findIndex(v => v > th) : onset(Math.round((s.t0 - A.voice.at) * FR))); // 每幕首句 = 音频第一个有声帧
14
+ A.subs.forEach((s, i) => { const t0 = A.voice.at + on[i] / FR - LEAD; stats.push(Math.round((t0 + LEAD - s.t0) * 1000)); s.t0 = +t0.toFixed(3);
15
+ const lim = i < on.length - 1 ? on[i + 1] : e.length; let last = on[i]; for (let f = on[i]; f < lim; f++) if (loud(f)) last = f;
16
+ s.t1 = +(A.voice.at + last / FR + TAIL).toFixed(3); });
17
+ A.subs.forEach((s, i) => { const n = A.subs[i + 1]; if (n && (s.t1 > n.t0 - .02 || n.t0 - s.t1 < .3)) s.t1 = +(n.t0 - .02).toFixed(3); if (s.t1 < s.t0 + .5) s.t1 = +(s.t0 + .5).toFixed(3); });
18
+ // 校验:每个有声帧都应被某条字幕覆盖
19
+ let miss = 0; e.forEach((v, f) => { if (v > th) { const t = A.voice.at + f / FR; if (!A.subs.some(s => t >= s.t0 && t <= s.t1)) miss++; } }); A._miss = miss;
20
+ }
21
+ const miss = TL.acts.reduce((a, A) => a + A._miss, 0); TL.acts.forEach(A => delete A._miss);
22
+ fs.writeFileSync(FILE, src.slice(0, src.indexOf('{')) + JSON.stringify(TL, null, 1) + src.slice(src.lastIndexOf('}') + 1));
23
+ console.log(`对齐 ${stats.length} 条;原始偏差 ${Math.min(...stats)}~${Math.max(...stats)} ms;有声却无字幕的帧 ${miss}`);
gemini/tools/narrate.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ♊ 双子座 —— 双声旁白生成(全新编写)
2
+ # 用法(在仓库根目录):PYTHONPATH=<含 edge_tts 的目录> python3 gemini/tools/narrate.py
3
+ # 输出:gemini/audio/n01..n10.mp3 与 gemini/engine/timeline.js
4
+ import asyncio, json, os, subprocess, sys, array
5
+ import edge_tts, imageio_ffmpeg
6
+
7
+ FF = imageio_ffmpeg.get_ffmpeg_exe()
8
+ ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
9
+ VOICE = {'f': 'zh-CN-XiaoxiaoNeural', 'm': 'zh-CN-YunxiNeural'}
10
+ RATE = {'f': '-4%', 'm': '-2%'}
11
+ SR = 24000
12
+ LINE_GAP = 0.32 # 句间停顿
13
+ LEAD = {0: 2.4}; DEFAULT_LEAD = 1.0
14
+ GAP = 1.3; OUTRO = 6.0
15
+
16
+ F, M, B = 'f', 'm', 'fm'
17
+ # 字幕显示用写法(朗读仍用汉字读音)
18
+ DISPLAY = {'二零零六年': '2006 年', '一七八一年': '1781 年', '一九三零年': '1930 年', '三十四光年': '34 光年', '五月二十一日到六月二十一日': '5 月 21 日到 6 月 21 日', '一百多颗': '100 多颗'}
19
+ def shown(t):
20
+ for a, b in DISPLAY.items(): t = t.replace(a, b)
21
+ return t.rstrip('。,、')
22
+ ACTS = [
23
+ ('twin', '序章 · 镜中的双星', 'dawn', [
24
+ (F, '五月的尾巴,夏天刚刚推开门。'), (M, '夜空里,有两颗星总是肩并着肩。'), (F, '一颗明亮温暖,'), (M, '一颗清澈闪烁。'),
25
+ (F, '它们像一面镜子的两边,'), (M, '又像同一个灵魂的两种声音。'), (F, '这就是黄道第三宫,双子座。'),
26
+ (M, '五月二十一日到六月二十一日出生的你,'), (F, '欢迎回到自己的星空。')]),
27
+ ('locate', '找到他们', 'night', [
28
+ (M, '冬天和早春的夜晚,先找到猎户座。'), (F, '从猎户脚下的参宿七出发,'), (M, '穿过肩头那颗橙红的参宿四,'), (F, '一直向东北方延伸。'),
29
+ (M, '你会看到两颗相距不远的亮星。'), (F, '西边那颗是北河二,卡斯托尔。'), (M, '东边更亮的那颗,是北河三,波吕克斯。'),
30
+ (F, '从两颗星向下,两串星星并排伸展,'), (M, '就像一对兄弟,手牵着手,站在银河边上。')]),
31
+ ('pair', '两颗星的秘密', 'violet', [
32
+ (F, '有意思的是,排名第一的北河二,'), (M, '其实没有北河三亮。'), (F, '北河三是一颗橙色的巨星,'), (M, '离我们大约三十四光年。'),
33
+ (F, '二零零六年,天文学家确认,它身边有一颗行星在转动。'), (M, '而北河二更神奇。'), (F, '它看起来是一颗星,'),
34
+ (M, '其实是六颗恒星组成的大家庭。'), (F, '三对双星彼此环绕,'), (M, '一起跳着一支很慢很慢的舞。'), (F, '你看,双子座连星星本身,都喜欢热闹。')]),
35
+ ('myth', '卡斯托尔与波吕克斯', 'amber', [
36
+ (M, '古希腊传说里,卡斯托尔和波吕克斯是一对孪生兄弟。'), (F, '一个擅长驯马,一个擅长拳击。'), (M, '他们一起航海,一起冒险,从不分开。'),
37
+ (F, '可是卡斯托尔是凡人,'), (M, '波吕克斯却拥有不死之身。'), (F, '当卡斯托尔在战斗中倒下,'), (M, '波吕克斯请求宙斯,'),
38
+ (F, '把自己的永生,分一半给兄弟。'), (M, '从此,他们一天在天上,一天在冥界,永远在一起。'), (F, '宙斯被这份情谊打动,把他们升上星空。'),
39
+ (M, '后来的水手们相信,看见桅杆上的蓝色火光,'), (F, '就是双子在为他们护航。')]),
40
+ ('china', '北河与井宿', 'ink', [
41
+ (F, '在中国古代的星空里,他们有一个好听的名字,'), (M, '北河。'), (F, '北河二,北河三,守在银河的北岸。'),
42
+ (M, '它们属于二十八宿中的井宿。'), (F, '井宿,是南方朱雀七宿的第一宿。'), (M, '古人看着它,观测天象,校准历法。'),
43
+ (F, '东方和西方,看见的是同一片星空,'), (M, '却讲出了不同的故事。')]),
44
+ ('discover', '两颗行星的发现', 'deep', [
45
+ (M, '双子座还有一个了不起的身份,'), (F, '发现之地。'), (M, '一七八一年,英国天文学家威廉·赫歇尔,'), (F, '在双子座里,看见一个缓缓移动的光点。'),
46
+ (M, '那是天王星,人类用望远镜发现的第一颗行星。'), (F, '一九三零年,美国青年克莱德·汤博,'), (M, '一张一张地比对星空底片。'),
47
+ (F, '他在双子座的星星之间,找到了冥王星。'), (M, '好奇心,在这里两次改写了太阳系的地图。')]),
48
+ ('meteor', '双子座流星雨', 'snow', [
49
+ (F, '每年十二月中旬,双子座还会送出一份礼物,'), (M, '双子座流星雨。'), (F, '它是一年中最壮观的流星雨之一,'),
50
+ (M, '高峰时,每小时可以看到一百多颗。'), (F, '它的源头不是彗星,'), (M, '而是一颗叫法厄同的小行星。'),
51
+ (F, '冬夜里抬头许愿吧,'), (M, '星星会从双子的方向飞来。')]),
52
+ ('sign', '风象 · 水星 · ♊', 'aurora', [
53
+ (F, '双子座的符号,像罗马数字二,'), (M, '两根柱子,被上下两道横梁连在一起。'), (F, '它属于风象星座,'), (M, '守护星是��星。'),
54
+ (F, '水星是众神的信使,跑得最快,消息最灵。'), (M, '所以人们说,双子座好奇,聪明,反应快,'), (F, '会表达,爱学习,'),
55
+ (M, '总能把陌生人变成朋友。'), (F, '有人说双子座善变,'), (M, '其实,那是一颗心里,装着两倍的世界。')]),
56
+ ('medal', '双星荣誉徽章', 'royal', [
57
+ (M, '所以,如果你是双子座,'), (F, '这枚双星荣誉徽章,属于你。'), (M, '一半是北河二的银,'), (F, '一半是北河三的金。'),
58
+ (M, '上面刻着你的名字,'), (F, '和你最闪亮的天赋,好奇,灵动,真诚。'), (M, '双子座,星空的信使。'), (F, '世界因为你的好奇,变得更有趣。')]),
59
+ ('coda', '尾声 · 两束光', 'dusk', [
60
+ (M, '下一次,夏天的风吹过来,'), (F, '抬头看看那两颗并肩的星星。'), (M, '你从来不是一个人,'), (F, '你是两束光,也是一整片星空。'),
61
+ (B, '双子座,保持好奇,闪闪发光。')]),
62
+ ]
63
+
64
+ async def tts(text, who):
65
+ com = edge_tts.Communicate(text, VOICE[who], rate=RATE[who], boundary='WordBoundary')
66
+ audio = bytearray(); words = []
67
+ async for ch in com.stream():
68
+ if ch['type'] == 'audio': audio += ch['data']
69
+ elif ch['type'] == 'WordBoundary': words.append((ch['offset'] / 1e7, (ch['offset'] + ch['duration']) / 1e7, ch['text']))
70
+ pcm = subprocess.run([FF, '-v', 'error', '-i', 'pipe:0', '-f', 's16le', '-ac', '1', '-ar', str(SR), 'pipe:1'], input=bytes(audio), capture_output=True, check=True).stdout
71
+ a = array.array('h'); a.frombytes(pcm)
72
+ # 去掉首尾静音(保留 40ms)
73
+ th = 500; i0 = next((i for i, v in enumerate(a) if abs(v) > th), 0); i1 = len(a) - next((i for i, v in enumerate(reversed(a)) if abs(v) > th), 0)
74
+ i0 = max(0, i0 - int(.04 * SR)); i1 = min(len(a), i1 + int(.06 * SR))
75
+ return a[i0:i1], [(s - i0 / SR, e - i0 / SR, w) for s, e, w in words]
76
+
77
+ async def line(who, text):
78
+ if who != B: return await tts(text, who)
79
+ (a, w), (b, _) = await tts(text, F), await tts(text, M)
80
+ n = max(len(a), len(b)); out = array.array('h', [0] * n)
81
+ for i in range(n):
82
+ v = (a[i] if i < len(a) else 0) * .62 + (b[i] if i < len(b) else 0) * .62
83
+ out[i] = int(max(-32767, min(32767, v)))
84
+ return out, w
85
+
86
+ async def main():
87
+ os.makedirs(os.path.join(ROOT, 'audio'), exist_ok=True)
88
+ t = 0.0; acts = []
89
+ for k, (aid, title, tone, lines) in enumerate(ACTS):
90
+ lead = LEAD.get(k, DEFAULT_LEAD); at = t + lead
91
+ pcm = array.array('h'); subs = []
92
+ for who, text in lines:
93
+ a, _ = await line(who, text)
94
+ st = len(pcm) / SR
95
+ subs.append({'t0': round(at + st, 3), 't1': round(at + st + len(a) / SR, 3), 'text': shown(text), 'who': who})
96
+ pcm += a; pcm += array.array('h', [0] * int(LINE_GAP * SR))
97
+ print(aid, who, text, flush=True)
98
+ dur = len(pcm) / SR - LINE_GAP
99
+ f = f'audio/n{k + 1:02d}.mp3'
100
+ subprocess.run([FF, '-v', 'error', '-y', '-f', 's16le', '-ar', str(SR), '-ac', '1', '-i', 'pipe:0', '-c:a', 'libmp3lame', '-b:a', '96k', os.path.join(ROOT, f)], input=pcm.tobytes(), check=True)
101
+ end = at + dur + GAP + (OUTRO if k == len(ACTS) - 1 else 0)
102
+ acts.append({'id': aid, 'title': title, 'tone': tone, 'start': round(t, 3), 'end': round(end, 3), 'voice': {'file': f, 'at': round(at, 3), 'dur': round(dur, 3)}, 'subs': subs})
103
+ t = end
104
+ tl = {'duration': round(t, 3), 'voices': VOICE, 'acts': acts}
105
+ with open(os.path.join(ROOT, 'engine/timeline.js'), 'w') as fp:
106
+ fp.write('// 由 tools/narrate.py 生成、tools/align.mjs 校准:双声旁白时间轴与字幕\nexport const TIMELINE = ' + json.dumps(tl, ensure_ascii=False, indent=1) + ';\n')
107
+ print('total', round(t, 2), 'subs', sum(len(a['subs']) for a in acts))
108
+
109
+ asyncio.run(main())