Files
wehub-resource-sync 8ef9db2513
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:56:49 +08:00

383 lines
16 KiB
Python

#!/usr/bin/env python3
"""Generate per-slide narration audio from PPT Master notes.
This script uses provider backends for the same per-slide output contract on
macOS, Linux, and Windows. `edge-tts` remains the default no-key backend.
Usage:
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider elevenlabs --voice-id <voice_id>
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider minimax --voice-id <voice_id>
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider qwen --voice-id <voice>
python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider cosyvoice --voice-id <voice>
python3 skills/ppt-master/scripts/notes_to_audio.py --list-common-voices
python3 skills/ppt-master/scripts/notes_to_audio.py --list-voices --locale zh-CN
Dependencies:
python3 -m pip install edge-tts
ELEVENLABS_API_KEY=<key> for --provider elevenlabs
MINIMAX_API_KEY=<key> for --provider minimax
QWEN_API_KEY or DASHSCOPE_API_KEY=<key> for --provider qwen
COSYVOICE_API_KEY or DASHSCOPE_API_KEY=<key> for --provider cosyvoice
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from console_encoding import configure_utf8_stdio
from config import load_prefixed_env_file
from tts_backends import (
backend_cosyvoice,
backend_edge,
backend_elevenlabs,
backend_minimax,
backend_qwen,
)
configure_utf8_stdio()
@dataclass(frozen=True)
class AudioBackend:
provider: str
extension: str
api_key: str = ""
voice_id: str = ""
def _load_tts_env_file() -> None:
"""Load TTS-related keys from the first .env file, without overriding shell env."""
load_prefixed_env_file((
"ELEVENLABS_",
"MINIMAX_",
"QWEN_",
"DASHSCOPE_",
"COSYVOICE_",
))
def spoken_text(markdown: str) -> str:
"""Return narration text exactly from notes, except Markdown headings."""
lines: list[str] = []
for raw in markdown.splitlines():
if raw.lstrip().startswith("#"):
continue
line = raw.rstrip()
if not line.strip():
if lines and lines[-1] != "":
lines.append("")
continue
lines.append(line)
return "\n".join(lines).strip()
def main() -> int:
_load_tts_env_file()
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_path", type=Path, nargs="?")
parser.add_argument("-o", "--output", type=Path, default=None)
parser.add_argument(
"--provider",
choices=["edge", "elevenlabs", "minimax", "qwen", "cosyvoice"],
default="edge",
help="audio generation backend (default: edge)",
)
parser.add_argument(
"--voice",
default=None,
help="edge-tts voice ShortName. For elevenlabs, --voice-id is preferred.",
)
parser.add_argument(
"--voice-id",
default=None,
help="provider voice ID/name. If omitted for cloud providers, --voice is used as a fallback.",
)
parser.add_argument(
"--rate",
default="+0%",
help='edge-tts speaking rate, e.g. "+0%%", "-10%%", "+15%%" (default: +0%%). Ignored by cloud providers.',
)
parser.add_argument(
"--elevenlabs-api-key-env",
default="ELEVENLABS_API_KEY",
help="environment variable containing the ElevenLabs API key (default: ELEVENLABS_API_KEY)",
)
parser.add_argument(
"--elevenlabs-model",
default="eleven_multilingual_v2",
help="ElevenLabs TTS model ID (default: eleven_multilingual_v2)",
)
parser.add_argument(
"--elevenlabs-output-format",
default="mp3_44100_128",
help="ElevenLabs output format (default: mp3_44100_128)",
)
parser.add_argument(
"--elevenlabs-stability",
type=float,
default=None,
help="optional ElevenLabs voice stability override, 0.0-1.0",
)
parser.add_argument(
"--elevenlabs-similarity-boost",
type=float,
default=None,
help="optional ElevenLabs similarity boost override, 0.0-1.0",
)
parser.add_argument(
"--elevenlabs-style",
type=float,
default=None,
help="optional ElevenLabs style exaggeration override, 0.0-1.0",
)
parser.add_argument(
"--elevenlabs-speaker-boost",
action=argparse.BooleanOptionalAction,
default=None,
help="optionally override ElevenLabs speaker boost",
)
parser.add_argument("--minimax-api-key-env", default="MINIMAX_API_KEY",
help="environment variable containing the MiniMax API key")
parser.add_argument("--minimax-model", default="speech-2.8-hd",
help="MiniMax T2A model ID (default: speech-2.8-hd)")
parser.add_argument("--minimax-base-url", default=None,
help="MiniMax T2A endpoint or base URL")
parser.add_argument("--minimax-output-format", default="mp3", choices=["mp3", "wav"],
help="MiniMax audio format for PPT narration (default: mp3)")
parser.add_argument("--minimax-sample-rate", type=int, default=32000,
help="MiniMax sample rate (default: 32000)")
parser.add_argument("--minimax-bitrate", type=int, default=128000,
help="MiniMax bitrate in bps (default: 128000)")
parser.add_argument("--minimax-channel", type=int, default=1,
help="MiniMax channel count (default: 1)")
parser.add_argument("--minimax-speed", type=float, default=1.0,
help="MiniMax speaking speed (default: 1.0)")
parser.add_argument("--minimax-volume", type=float, default=1.0,
help="MiniMax volume multiplier (default: 1.0)")
parser.add_argument("--minimax-pitch", type=int, default=0,
help="MiniMax pitch adjustment (default: 0)")
parser.add_argument("--minimax-language-boost", default="auto",
help="MiniMax language boost (default: auto)")
parser.add_argument("--qwen-api-key-env", default=None,
help="environment variable containing the Qwen/DashScope API key")
parser.add_argument("--qwen-model", default="qwen3-tts-flash",
help="Qwen TTS model ID (default: qwen3-tts-flash)")
parser.add_argument("--qwen-base-url", default=None,
help="Qwen TTS endpoint or base URL")
parser.add_argument("--qwen-language-type", default="Chinese",
help="Qwen language_type, e.g. Chinese or English (default: Chinese)")
parser.add_argument("--qwen-instructions", default=None,
help="optional Qwen instruction text for supported models")
parser.add_argument("--qwen-optimize-instructions", action=argparse.BooleanOptionalAction,
default=None, help="optionally ask Qwen to optimize instructions")
parser.add_argument("--cosyvoice-api-key-env", default="COSYVOICE_API_KEY",
help="environment variable containing the CosyVoice/DashScope API key")
parser.add_argument("--cosyvoice-model", default="cosyvoice-v3-flash",
help="CosyVoice model ID (default: cosyvoice-v3-flash)")
parser.add_argument("--cosyvoice-base-url", default=None,
help="CosyVoice SpeechSynthesizer endpoint or base URL")
parser.add_argument("--cosyvoice-output-format", default="mp3", choices=["mp3", "wav"],
help="CosyVoice audio format for PPT narration (default: mp3)")
parser.add_argument("--cosyvoice-sample-rate", type=int, default=24000,
help="CosyVoice sample rate (default: 24000)")
parser.add_argument("--cosyvoice-volume", type=int, default=None,
help="optional CosyVoice volume, 0-100")
parser.add_argument("--cosyvoice-rate", type=float, default=None,
help="optional CosyVoice speaking rate, 0.5-2.0")
parser.add_argument("--cosyvoice-pitch", type=float, default=None,
help="optional CosyVoice pitch multiplier, 0.5-2.0")
parser.add_argument("--cosyvoice-instruction", default=None,
help="optional CosyVoice instruction text for supported voices/models")
parser.add_argument("--cosyvoice-language-hint", default=None,
help="optional CosyVoice language hint, e.g. zh, en, ja")
parser.add_argument("--list-common-voices", action="store_true", help="print a curated voice list and exit")
parser.add_argument("--list-voices", action="store_true", help="query provider voices and exit")
parser.add_argument("--locale", default=None, help='filter --list-voices by locale, e.g. "zh-CN"')
args = parser.parse_args()
if args.list_common_voices:
backend_edge.print_common_voices()
return 0
if args.list_voices:
try:
if args.provider == "elevenlabs":
backend_elevenlabs.print_voices(
backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
)
elif args.provider == "minimax":
backend_minimax.print_voices()
elif args.provider == "qwen":
backend_qwen.print_voices()
elif args.provider == "cosyvoice":
backend_cosyvoice.print_voices()
else:
asyncio.run(backend_edge.print_voices(args.locale))
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
return 0
if args.project_path is None:
parser.error("project_path is required unless --list-voices or --list-common-voices is used")
voice_id = args.voice_id or args.voice
if args.provider == "edge" and not args.voice:
parser.error(
"--voice is required for --provider edge. Run --list-voices --locale <locale> to discover voices "
"(e.g. --locale zh-CN), or follow skills/ppt-master/workflows/generate-audio.md "
"for an AI-curated recommendation."
)
raise AssertionError("unreachable")
if args.provider != "edge" and not voice_id:
parser.error(f"--voice-id is required for --provider {args.provider}")
raise AssertionError("unreachable")
if args.provider == "elevenlabs":
if not voice_id:
parser.error("--voice-id is required for --provider elevenlabs")
raise AssertionError("unreachable")
try:
api_key = backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
extension = backend_elevenlabs.output_extension(args.elevenlabs_output_format)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
elif args.provider == "minimax":
try:
api_key = backend_minimax.read_minimax_api_key(args.minimax_api_key_env)
extension = backend_minimax.output_extension(args.minimax_output_format)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
elif args.provider == "qwen":
try:
api_key = backend_qwen.read_qwen_api_key(args.qwen_api_key_env)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(
provider=args.provider,
extension=backend_qwen.output_extension(),
api_key=api_key,
voice_id=voice_id,
)
elif args.provider == "cosyvoice":
try:
api_key = backend_cosyvoice.read_cosyvoice_api_key(args.cosyvoice_api_key_env)
extension = backend_cosyvoice.output_extension(args.cosyvoice_output_format)
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
else:
backend = AudioBackend(provider=args.provider, extension=backend_edge.edge_output_extension(), voice_id=args.voice)
project = args.project_path
notes_dir = project / "notes"
output_dir = args.output or (project / "audio")
output_dir.mkdir(parents=True, exist_ok=True)
note_files = [
path for path in sorted(notes_dir.glob("*.md"))
if path.name != "total.md"
]
if not note_files:
print(f"error: no per-slide notes found in {notes_dir}", file=sys.stderr)
return 2
generated = 0
for note_path in note_files:
text = spoken_text(note_path.read_text(encoding="utf-8"))
if not text:
print(f"[skip] {note_path.name}: empty spoken text")
continue
output_path = output_dir / f"{note_path.stem}{backend.extension}"
try:
if backend.provider == "elevenlabs":
backend_elevenlabs.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.elevenlabs_model,
output_format=args.elevenlabs_output_format,
stability=args.elevenlabs_stability,
similarity_boost=args.elevenlabs_similarity_boost,
style=args.elevenlabs_style,
speaker_boost=args.elevenlabs_speaker_boost,
)
elif backend.provider == "minimax":
backend_minimax.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.minimax_model,
audio_format=args.minimax_output_format,
sample_rate=args.minimax_sample_rate,
bitrate=args.minimax_bitrate,
channel=args.minimax_channel,
speed=args.minimax_speed,
volume=args.minimax_volume,
pitch=args.minimax_pitch,
language_boost=args.minimax_language_boost,
base_url=args.minimax_base_url,
)
elif backend.provider == "qwen":
backend_qwen.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.qwen_model,
language_type=args.qwen_language_type,
instructions=args.qwen_instructions,
optimize_instructions=args.qwen_optimize_instructions,
base_url=args.qwen_base_url,
)
elif backend.provider == "cosyvoice":
backend_cosyvoice.generate(
text,
output_path,
api_key=backend.api_key,
voice_id=backend.voice_id,
model=args.cosyvoice_model,
audio_format=args.cosyvoice_output_format,
sample_rate=args.cosyvoice_sample_rate,
volume=args.cosyvoice_volume,
rate=args.cosyvoice_rate,
pitch=args.cosyvoice_pitch,
instruction=args.cosyvoice_instruction,
language_hint=args.cosyvoice_language_hint,
base_url=args.cosyvoice_base_url,
)
else:
asyncio.run(backend_edge.generate(text, output_path, voice=args.voice, rate=args.rate))
except Exception as exc:
print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
return 1
generated += 1
print(f"[OK] {output_path}")
print(f"[Done] Generated {generated}/{len(note_files)} audio file(s): {output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())