383 lines
16 KiB
Python
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())
|