Files
oil-formula-calculator/frontend/scripts/generate-tts.py
Hera Zhao ee8ec23dc7 Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E
- Replace single-file 8441-line HTML with Vue 3 SPA
- Pinia stores: auth, oils, recipes, diary, ui
- Composables: useApi, useDialog, useSmartPaste, useOilTranslation
- 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc.
- 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc.
- 14 Cypress E2E test specs (113 tests), all passing
- Multi-stage Dockerfile (Node build + Python runtime)
- Demo video generation scripts (TTS + subtitles + screen recording)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:35:00 +00:00

143 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate TTS narration for the demo video using edge-tts.
Uses XiaoxiaoNeural with natural pacing and SSML prosody control."""
import asyncio
import sys
import os
import struct
import wave
OUTPUT_DIR = sys.argv[1] if len(sys.argv) > 1 else "demo-output"
# (start_seconds, text, [optional: pause_after_ms])
# Timeline aligned with demo-subtitles.srt and demo-walkthrough.cy.js
SEGMENTS = [
(0.5, '欢迎使用 doTERRA 精油配方计算器。这是一个功能完整的精油配方管理工具。'),
(5.0, '首页展示了所有公共配方,以卡片形式排列。'),
(9.0, '可以上下滚动,浏览更多配方。'),
(12.0, '在搜索框输入关键词,可以快速筛选配方。'),
(16.0, '输入薰衣草,立即过滤出包含薰衣草的配方。'),
(20.0, '点击任意配方卡片,查看配方详情。'),
(24.5, '详情页显示精油成分、用量、单价、和总成本。'),
(30.0, '切换到精油价目页面,查看所有精油价格。'),
(34.5, '支持按名称搜索精油。'),
(38.0, '可以切换瓶价和滴价两种显示模式。'),
(42.0, '管理配方页面,用于配方的增删改查和批量操作。'),
(47.0, '支持标签筛选、批量打标签、批量导出卡片。'),
(52.0, '个人库存页面,标记自己拥有的精油。'),
(56.5, '系统会自动推荐你能制作的配方。'),
(60.0, '操作日志记录所有数据变更,支持按类型和用户筛选。'),
(66.0, '内置Bug追踪系统支持优先级、状态流转和评论。'),
(72.0, '用户管理可以创建用户、分配角色、审核翻译建议。'),
(78.0, '最后回到首页。演示到此结束。'),
(82.5, '感谢观看这是基于Vue 3、Vite和Pinia构建的现代化应用。'),
]
VOICE = "zh-CN-XiaoxiaoNeural"
RATE = "+0%"
PITCH = "+0Hz"
try:
import edge_tts
except ImportError:
print("Installing edge-tts...")
os.system(f"{sys.executable} -m pip install edge-tts -q")
import edge_tts
async def generate_segment(text, output_path):
"""Generate a single TTS segment with natural prosody."""
communicate = edge_tts.Communicate(text, VOICE, rate=RATE, pitch=PITCH)
await communicate.save(output_path)
async def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
segment_files = []
print(f"Generating {len(SEGMENTS)} TTS segments with {VOICE}...")
for i, (start_sec, text) in enumerate(SEGMENTS):
seg_path = os.path.join(OUTPUT_DIR, f"seg_{i:02d}.mp3")
print(f" [{i+1}/{len(SEGMENTS)}] {start_sec:5.1f}s {text[:40]}...")
await generate_segment(text, seg_path)
segment_files.append((start_sec, seg_path))
# Total duration: last segment start + 8s buffer
total_duration = SEGMENTS[-1][0] + 8
print(f"\nAssembling narration track ({total_duration:.0f}s)...")
# Generate silence base
silence_path = os.path.join(OUTPUT_DIR, "silence.mp3")
os.system(
f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=mono '
f'-t {total_duration} -c:a libmp3lame -q:a 2 "{silence_path}" 2>/dev/null'
)
# Build ffmpeg complex filter to overlay each segment at its timestamp
inputs = [f'-i "{silence_path}"']
filter_parts = []
for i, (start_sec, seg_path) in enumerate(segment_files):
inputs.append(f'-i "{seg_path}"')
delay_ms = int(start_sec * 1000)
# Normalize volume for each segment
filter_parts.append(f'[{i+1}]adelay={delay_ms}|{delay_ms},volume=1.5[d{i}]')
# Mix all
mix_inputs = "[0]" + "".join(f"[d{i}]" for i in range(len(segment_files)))
n_inputs = len(segment_files) + 1
filter_parts.append(
f'{mix_inputs}amix=inputs={n_inputs}:duration=longest:dropout_transition=0,'
f'volume={n_inputs}[out]'
)
filter_str = ";".join(filter_parts)
output_path = os.path.join(OUTPUT_DIR, "narration.mp3")
cmd = (
f'ffmpeg -y {" ".join(inputs)} '
f'-filter_complex "{filter_str}" '
f'-map "[out]" -c:a libmp3lame -q:a 2 "{output_path}" 2>/dev/null'
)
ret = os.system(cmd)
if ret != 0:
print("ERROR: ffmpeg narration assembly failed")
print("Trying fallback: simple concatenation...")
# Fallback: just concatenate with silence gaps
concat_list = os.path.join(OUTPUT_DIR, "concat.txt")
with open(concat_list, "w") as f:
prev_end = 0
for start_sec, seg_path in segment_files:
gap = start_sec - prev_end
if gap > 0:
gap_path = os.path.join(OUTPUT_DIR, f"gap_{prev_end:.0f}.mp3")
os.system(f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=mono -t {gap} -c:a libmp3lame -q:a 2 "{gap_path}" 2>/dev/null')
f.write(f"file '{gap_path}'\n")
f.write(f"file '{seg_path}'\n")
# Estimate segment duration ~3s
prev_end = start_sec + 3
os.system(f'ffmpeg -y -f concat -safe 0 -i "{concat_list}" -c:a libmp3lame -q:a 2 "{output_path}" 2>/dev/null')
# Cleanup
for _, seg_path in segment_files:
try: os.remove(seg_path)
except: pass
try: os.remove(silence_path)
except: pass
print(f"\nDone! Output: {output_path}")
if os.path.exists(output_path):
size = os.path.getsize(output_path) / 1024
print(f"Size: {size:.0f} KB")
else:
print("ERROR: Output file not created")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())