POLS / taurus /tools /voice.py
Ethscriptions's picture
taurus: 旁白配音、字幕与时间轴
7e74f3a verified
Raw History Blame Contribute Delete
8.17 kB
#!/usr/bin/env python3
"""♉ 金牛座 · 旁白合成 + 字幕时间轴生成(全新编写)
用法(在仓库根 pols/ 下):PYTHONPATH=../py python3 taurus/tools/voice.py
输出:taurus/audio/v01..v10.mp3、taurus/js/script.js
"""
import asyncio, json, os, re, subprocess, sys
import edge_tts, imageio_ffmpeg
VOICE, RATE = 'zh-CN-YunyangNeural', '-3%'
HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.dirname(HERE)
FF = imageio_ffmpeg.get_ffmpeg_exe()
ACTS = [
('bloom', '序章 · 繁花里的公牛', 'meadow',
'四月下旬,春天走到了最繁茂的时候。花开满山,麦苗拔节,连风都是甜的。夜空里,一头健壮的金色公牛,正从繁花中缓缓走来。它不急不躁,每一步都踏得很稳。它就是黄道第二宫,金牛座。四月二十日到五月二十日出生的朋友,都属于它。'),
('ancient', '最古老的星座', 'dusk',
'金牛座,可能是人类最早命名的星座之一。五千多年前,春分时节的太阳,就落在金牛座里,那正是播种的季节。古代美索不达米亚人,把它叫作“天上的公牛”。而在法国拉斯科洞穴里,一万七千年前的壁画上画着一头公牛,它身旁的几个小点,有人认为,就是昴星团。'),
('locate', '找到它', 'night',
'想在夜空中找到金牛座并不难。冬天的晚上,先找到猎户座腰带上的三颗星,顺着它们往右上方延伸,就会遇到一颗橙红色的亮星,那是公牛的眼睛,毕宿五。它身边一群排成V字形的星星,是毕星团,组成了公牛的脸。两颗星向远处伸出,是长长的牛角。再往前,就是闪闪发光的昴星团。'),
('stars', '毕宿五与昴星团', 'night',
'毕宿五是一颗红巨星,直径大约是太阳的四十多倍,距离我们约六十五光年。它的英文名,来自阿拉伯语,意思是“追随者”,因为它总是跟在昴星团后面升起。昴星团,西方叫它“七姐妹”。用肉眼能看到六七颗星,用望远镜看,却有上千颗年轻的蓝色恒星。日本把它叫作“昴”,汽车品牌斯巴鲁的标志,画的就是它。'),
('guest', '天关客星', 'ink',
'金牛座里,还藏着一段属于中国人的骄傲。公元一零五四年,北宋的天文官记录下,天关星附近,忽然出现了一颗“客星”。它亮到白天都能看见,一连二十三天。后来,天文学家在那个位置,找到了一团正在膨胀的星云,蟹状星云。它正是那次超新星爆发留下的遗迹,中心还有一颗每秒自转三十次的脉冲星。'),
('europa', '欧罗巴与白牛', 'sea',
'希腊神话里,众神之王宙斯,爱上了美丽的公主欧罗巴。他化身为一头雪白温顺的公牛,在海边的花丛中静静等待。公主为它编了花环,轻轻坐上了牛背。公牛便驮着她踏浪而去,一直游到了克里特岛。后来,欧罗巴的名字,成了“欧洲”一词的来源。而那头白色的公牛,被升上夜空,成了金牛座。'),
('sigil', '♉ 的含义', 'rose',
'金牛座的符号,是一个圆,加上一弯向上的弧线。圆是公牛的脸,弧线是一对牛角。也有人说,它像一弯新月,托起了一轮满月,象征着丰收与富足。'),
('venus', '土象 · 金星', 'earth',
'在星座文化里,金牛座属于土象星座,守护星是金星,象征着爱与美。所以人们常说,金牛座稳重踏实,有耐心,重承诺。他们懂得享受生活,爱美食,也爱一切美好的事物。看起来慢热,可一旦认定了,就会稳稳地守护到底。'),
('card', '鎏金珍藏卡', 'gilt',
'所以,如果你是金牛座,这张金色的珍藏卡,属于你。它镌刻着你的星座,你的守护星,还有你最珍贵的品质:坚定,温柔,可靠。因为这个世界,需要你这样稳稳的力量。金牛座,大地的守护者。'),
('coda', '尾声 · 慢慢来', 'sunset',
'每年春末,当花开到最盛,太阳正缓缓走过金牛座。愿你按照自己的节奏,慢慢来,也能抵达所有的美好。'),
]
LEAD = {0: 2.6}; DEFAULT_LEAD = 1.0; GAP = 1.4; OUTRO = 5.5
PUNCT = ',。?!;:、,.!?;:'
QUOTE = '“”"‘’「」'
async def synth(text, path):
com = edge_tts.Communicate(text, VOICE, rate=RATE, boundary='WordBoundary')
words = []
with open(path, 'wb') as f:
async for ch in com.stream():
if ch['type'] == 'audio': f.write(ch['data'])
elif ch['type'] == 'WordBoundary':
words.append((ch['offset'] / 1e7, (ch['offset'] + ch['duration']) / 1e7, ch['text']))
return words
def duration(path):
out = subprocess.run([FF, '-i', path, '-f', 'null', '-'], capture_output=True, text=True).stderr
t = re.findall(r'time=(\d+):(\d+):([\d.]+)', out)[-1]
return int(t[0]) * 3600 + int(t[1]) * 60 + float(t[2])
def char_times(text, words):
"""为原文每个字符估计 (起, 止) 时间。"""
n = len(text); st = [None] * n; en = [None] * n; cur = 0
for a, b, w in words:
w = w.strip()
if not w: continue
k = text.find(w, cur)
if k < 0 or k - cur > 12: continue
L = len(w)
for j in range(L): st[k + j] = a + (b - a) * j / L; en[k + j] = a + (b - a) * (j + 1) / L
cur = k + L
# 补齐未匹配的字符(标点等):向前/向后最近值
last = 0.0
for i in range(n):
if st[i] is None: st[i] = last; en[i] = last
else: last = en[i]
return st, en
def clauses(text):
"""按标点切成字幕短句;过长再对半,过短合并。返回 [(i0, i1)] 原文区间(含标点)。"""
segs, s = [], 0
for i, c in enumerate(text):
if c in PUNCT: segs.append((s, i + 1)); s = i + 1
if s < len(text): segs.append((s, len(text)))
vis = lambda a, b: len([c for c in text[a:b] if c not in PUNCT + QUOTE])
out = []
for a, b in segs:
if out:
pa, pb = out[-1]
soft = text[pb - 1] in ',、,::'
if soft and (vis(pa, pb) <= 3 or vis(a, b) <= 3) and vis(pa, b) <= 20:
out[-1] = (pa, b); continue
out.append((a, b))
return out
def clean(s):
s = s.strip()
while s and s[-1] in ',。、;:,;:': s = s[:-1]
return s
async def main():
os.makedirs(os.path.join(ROOT, 'audio'), exist_ok=True)
acts, t = [], 0.0
for k, (aid, title, tone, text) in enumerate(ACTS):
mp3 = os.path.join(ROOT, 'audio', f'v{k + 1:02d}.mp3')
words = await synth(text, mp3)
dur = duration(mp3)
lead = LEAD.get(k, DEFAULT_LEAD); at = t + lead
st, en = char_times(text, words)
subs = []
for a, b in clauses(text):
idx = [i for i in range(a, b) if text[i] not in PUNCT + QUOTE]
if not idx: continue
subs.append({'t0': round(at + st[idx[0]], 3), 't1': round(at + en[idx[-1]], 3), 'text': clean(text[a:b])})
for i in range(len(subs) - 1): # 字幕首尾相接,避免闪烁
if subs[i + 1]['t0'] - subs[i]['t1'] < .35: subs[i]['t1'] = subs[i + 1]['t0']
end = at + dur + GAP + (OUTRO if k == len(ACTS) - 1 else 0)
acts.append({'id': aid, 'title': title, 'tone': tone, 'start': round(t, 3), 'end': round(end, 3),
'voice': {'file': f'audio/v{k + 1:02d}.mp3', 'at': round(at, 3), 'dur': round(dur, 3)}, 'subs': subs, 'text': text})
print(f'{k + 1:02d} {aid:8s} start={t:7.2f} dur={dur:5.2f} subs={len(subs)} words={len(words)}')
t = end
film = {'duration': round(t, 3), 'voice': VOICE, 'acts': acts}
with open(os.path.join(ROOT, 'js', 'script.js'), 'w', encoding='utf-8') as f:
f.write('// 由 tools/voice.py 自动生成:旁白时间轴与字幕\nexport const FILM = ' + json.dumps(film, ensure_ascii=False, indent=1) + ';\n')
print('total', round(t, 2), 's;', sum(len(a['subs']) for a in acts), 'subtitles')
if __name__ == '__main__':
os.makedirs(os.path.join(ROOT, 'js'), exist_ok=True)
asyncio.run(main())