Spaces:
Running
Running
Download taurus/tools/voice.py from Ethscriptions/POLS: direct link, hf CLI and curl.
- Browser
- Download file 8.17 kB
-
https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/taurus/tools/voice.py
- Command line
-
hf download hf://spaces/Ethscriptions/POLS/taurus/tools/voice.py
-
curl -L -o voice.py https://huggingface.co/spaces/Ethscriptions/POLS/resolve/main/taurus/tools/voice.py
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()) | |