返回 ppt-master
notes_to_audio.py
根目录 / skills / ppt-master / scripts / notes_to_audio.py
1 #!/usr/bin/env python3
2 """Generate per-slide narration audio from PPT Master notes.
3
4 This script uses provider backends for the same per-slide output contract on
5 macOS, Linux, and Windows. `edge-tts` remains the default no-key backend and
6 also writes one compact, word-timed SRT file per slide from the same TTS stream.
7
8 Usage:
9 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
10 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider elevenlabs --voice-id <voice_id>
11 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider minimax --voice-id <voice_id>
12 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider qwen --voice-id <voice>
13 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider cosyvoice --voice-id <voice>
14 python3 skills/ppt-master/scripts/notes_to_audio.py --list-common-voices
15 python3 skills/ppt-master/scripts/notes_to_audio.py --list-voices --locale zh-CN
16
17 Dependencies:
18 python3 -m pip install edge-tts
19 ELEVENLABS_API_KEY=<key> for --provider elevenlabs
20 MINIMAX_API_KEY=<key> for --provider minimax
21 QWEN_API_KEY or DASHSCOPE_API_KEY=<key> for --provider qwen
22 COSYVOICE_API_KEY or DASHSCOPE_API_KEY=<key> for --provider cosyvoice
23 """
24
25 from __future__ import annotations
26
27 import argparse
28 import asyncio
29 import json
30 import os
31 import re
32 import sys
33 from dataclasses import dataclass
34 from pathlib import Path
35
36 from console_encoding import configure_utf8_stdio
37 from config import load_prefixed_env_file
38 from slide_roster import discover_slide_svgs
39 from tts_backends import (
40 backend_cosyvoice,
41 backend_edge,
42 backend_elevenlabs,
43 backend_minimax,
44 backend_qwen,
45 )
46
47 configure_utf8_stdio()
48
49 DEFAULT_EDGE_CONCURRENCY = 3
50 SUPPORTED_AUDIO_EXTENSIONS = frozenset({".m4a", ".mp3", ".wav"})
51
52
53 @dataclass(frozen=True)
54 class AudioBackend:
55 provider: str
56 extension: str
57 api_key: str = ""
58 voice_id: str = ""
59
60
61 @dataclass(frozen=True)
62 class NoteRosterEntry:
63 note_path: Path
64 output_stem: str
65
66
67 @dataclass(frozen=True)
68 class AudioJob:
69 note_path: Path
70 text: str
71 output_path: Path
72
73
74 def _load_tts_env_file() -> None:
75 """Load TTS-related keys from the first .env file, without overriding shell env."""
76 load_prefixed_env_file((
77 "ELEVENLABS_",
78 "MINIMAX_",
79 "QWEN_",
80 "DASHSCOPE_",
81 "COSYVOICE_",
82 ))
83
84
85 def spoken_text(markdown: str) -> str:
86 """Return narration text exactly from notes, except Markdown headings."""
87 lines: list[str] = []
88 for raw in markdown.splitlines():
89 if raw.lstrip().startswith("#"):
90 continue
91 line = raw.rstrip()
92 if not line.strip():
93 if lines and lines[-1] != "":
94 lines.append("")
95 continue
96 lines.append(line)
97 return "\n".join(lines).strip()
98
99
100 def _prepare_audio_jobs(
101 note_roster: list[NoteRosterEntry],
102 output_dir: Path,
103 extension: str,
104 ) -> list[AudioJob]:
105 """Read a complete per-slide notes roster into ordered audio jobs."""
106 jobs: list[AudioJob] = []
107 invalid: list[str] = []
108 for entry in note_roster:
109 note_path = entry.note_path
110 if not note_path.is_file():
111 invalid.append(f"{note_path.name} is missing")
112 continue
113 try:
114 text = spoken_text(note_path.read_text(encoding="utf-8"))
115 except (OSError, UnicodeError) as exc:
116 invalid.append(f"{note_path.name} is unreadable: {exc}")
117 continue
118 if not text:
119 invalid.append(f"{note_path.name} has no spoken text")
120 continue
121 jobs.append(AudioJob(
122 note_path=note_path,
123 text=text,
124 output_path=output_dir / f"{entry.output_stem}{extension}",
125 ))
126 if invalid:
127 raise ValueError(
128 "per-slide notes are incomplete: " + "; ".join(invalid)
129 )
130 return jobs
131
132
133 def _expected_note_roster(project: Path) -> list[NoteRosterEntry]:
134 """Resolve the owning route's complete per-slide notes roster."""
135 notes_dir = project / "notes"
136 svg_files = discover_slide_svgs(project / "svg_output")
137 if svg_files:
138 aliases: dict[int, list[Path]] = {}
139 for path in sorted(notes_dir.glob("*.md")):
140 match = re.search(r"slide[_]?(\d+)", path.stem)
141 if match:
142 aliases.setdefault(int(match.group(1)), []).append(path)
143 note_roster: list[NoteRosterEntry] = []
144 for index, svg_path in enumerate(svg_files, 1):
145 exact = notes_dir / f"{svg_path.stem}.md"
146 if exact.exists():
147 note_roster.append(NoteRosterEntry(
148 note_path=exact,
149 output_stem=svg_path.stem,
150 ))
151 continue
152 matches = aliases.get(index, [])
153 if len(matches) > 1:
154 raise ValueError(
155 f"multiple notes files match slide {index}: "
156 + ", ".join(path.name for path in matches)
157 )
158 note_roster.append(
159 NoteRosterEntry(
160 note_path=matches[0] if matches else exact,
161 output_stem=svg_path.stem,
162 )
163 )
164 return note_roster
165
166 slide_index_path = project / "analysis" / "slide_index.json"
167 if slide_index_path.is_file():
168 try:
169 slide_index = json.loads(
170 slide_index_path.read_text(encoding="utf-8")
171 )
172 except (OSError, UnicodeError, json.JSONDecodeError) as exc:
173 raise ValueError(f"invalid slide index: {exc}") from exc
174 if not isinstance(slide_index, dict):
175 raise ValueError("invalid slide index root")
176 slides = slide_index.get("slides")
177 slide_count = slide_index.get("slide_count")
178 if (
179 not isinstance(slides, list)
180 or isinstance(slide_count, bool)
181 or not isinstance(slide_count, int)
182 or slide_count != len(slides)
183 ):
184 raise ValueError("invalid slide index notes roster")
185 note_roster: list[NoteRosterEntry] = []
186 for index, slide in enumerate(slides, 1):
187 note_file = slide.get("note_file") if isinstance(slide, dict) else None
188 if not isinstance(note_file, str) or Path(note_file).suffix != ".md":
189 raise ValueError(
190 f"invalid slide index note_file for slide {index}"
191 )
192 note_name = Path(note_file).name
193 note_roster.append(
194 NoteRosterEntry(
195 note_path=notes_dir / note_name,
196 output_stem=Path(note_name).stem,
197 )
198 )
199 return note_roster
200
201 return [
202 NoteRosterEntry(
203 note_path=path,
204 output_stem=path.stem,
205 )
206 for path in sorted(notes_dir.glob("*.md"))
207 if path.name != "total.md"
208 ]
209
210
211 def _remove_stale_audio_variants(output_path: Path) -> None:
212 """Remove other supported formats only after the target audio is published."""
213 for candidate in output_path.parent.iterdir():
214 if (
215 candidate.name != output_path.name
216 and candidate.is_file()
217 and candidate.stem == output_path.stem
218 and candidate.suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS
219 ):
220 candidate.unlink()
221
222
223 async def _generate_edge_jobs(
224 jobs: list[AudioJob],
225 subtitle_dir: Path,
226 *,
227 voice: str,
228 rate: str,
229 subtitle_max_chars: int,
230 concurrency: int,
231 ) -> list[BaseException | None]:
232 """Generate ordered Edge jobs with bounded slide-level concurrency."""
233 semaphore = asyncio.Semaphore(concurrency)
234
235 async def generate_job(job: AudioJob) -> None:
236 async with semaphore:
237 await backend_edge.generate(
238 job.text,
239 job.output_path,
240 voice=voice,
241 rate=rate,
242 subtitle_path=subtitle_dir / f"{job.output_path.stem}.srt",
243 subtitle_max_chars=subtitle_max_chars,
244 )
245
246 raw_results = await asyncio.gather(
247 *(generate_job(job) for job in jobs),
248 return_exceptions=True,
249 )
250 return [
251 result if isinstance(result, BaseException) else None
252 for result in raw_results
253 ]
254
255
256 def main() -> int:
257 _load_tts_env_file()
258
259 parser = argparse.ArgumentParser(
260 description=__doc__,
261 formatter_class=argparse.RawDescriptionHelpFormatter,
262 )
263 parser.add_argument("project_path", type=Path, nargs="?")
264 parser.add_argument("-o", "--output", type=Path, default=None)
265 parser.add_argument(
266 "--provider",
267 choices=["edge", "elevenlabs", "minimax", "qwen", "cosyvoice"],
268 default="edge",
269 help="audio generation backend (default: edge)",
270 )
271 parser.add_argument(
272 "--voice",
273 default=None,
274 help="edge-tts voice ShortName. For elevenlabs, --voice-id is preferred.",
275 )
276 parser.add_argument(
277 "--voice-id",
278 default=None,
279 help="provider voice ID/name. If omitted for cloud providers, --voice is used as a fallback.",
280 )
281 parser.add_argument(
282 "--rate",
283 default="+0%",
284 help='edge-tts speaking rate, e.g. "+0%%", "-10%%", "+15%%" (default: +0%%). Ignored by cloud providers.',
285 )
286 parser.add_argument(
287 "--concurrency",
288 type=int,
289 default=DEFAULT_EDGE_CONCURRENCY,
290 help="maximum concurrent Edge slide requests (default: 3; ignored by cloud providers)",
291 )
292 parser.add_argument(
293 "--subtitle-max-chars",
294 type=int,
295 default=backend_edge.DEFAULT_SUBTITLE_MAX_CHARS,
296 help="maximum visible characters per Edge subtitle cue (default: 20)",
297 )
298 parser.add_argument(
299 "--elevenlabs-api-key-env",
300 default="ELEVENLABS_API_KEY",
301 help="environment variable containing the ElevenLabs API key (default: ELEVENLABS_API_KEY)",
302 )
303 parser.add_argument(
304 "--elevenlabs-model",
305 default="eleven_multilingual_v2",
306 help="ElevenLabs TTS model ID (default: eleven_multilingual_v2)",
307 )
308 parser.add_argument(
309 "--elevenlabs-output-format",
310 default="mp3_44100_128",
311 help="ElevenLabs output format (default: mp3_44100_128)",
312 )
313 parser.add_argument(
314 "--elevenlabs-stability",
315 type=float,
316 default=None,
317 help="optional ElevenLabs voice stability override, 0.0-1.0",
318 )
319 parser.add_argument(
320 "--elevenlabs-similarity-boost",
321 type=float,
322 default=None,
323 help="optional ElevenLabs similarity boost override, 0.0-1.0",
324 )
325 parser.add_argument(
326 "--elevenlabs-style",
327 type=float,
328 default=None,
329 help="optional ElevenLabs style exaggeration override, 0.0-1.0",
330 )
331 parser.add_argument(
332 "--elevenlabs-speaker-boost",
333 action=argparse.BooleanOptionalAction,
334 default=None,
335 help="optionally override ElevenLabs speaker boost",
336 )
337 parser.add_argument("--minimax-api-key-env", default="MINIMAX_API_KEY",
338 help="environment variable containing the MiniMax API key")
339 parser.add_argument("--minimax-model", default="speech-2.8-hd",
340 help="MiniMax T2A model ID (default: speech-2.8-hd)")
341 parser.add_argument("--minimax-base-url", default=None,
342 help="MiniMax T2A endpoint or base URL")
343 parser.add_argument("--minimax-output-format", default="mp3", choices=["mp3", "wav"],
344 help="MiniMax audio format for PPT narration (default: mp3)")
345 parser.add_argument("--minimax-sample-rate", type=int, default=32000,
346 help="MiniMax sample rate (default: 32000)")
347 parser.add_argument("--minimax-bitrate", type=int, default=128000,
348 help="MiniMax bitrate in bps (default: 128000)")
349 parser.add_argument("--minimax-channel", type=int, default=1,
350 help="MiniMax channel count (default: 1)")
351 parser.add_argument("--minimax-speed", type=float, default=1.0,
352 help="MiniMax speaking speed (default: 1.0)")
353 parser.add_argument("--minimax-volume", type=float, default=1.0,
354 help="MiniMax volume multiplier (default: 1.0)")
355 parser.add_argument("--minimax-pitch", type=int, default=0,
356 help="MiniMax pitch adjustment (default: 0)")
357 parser.add_argument("--minimax-language-boost", default="auto",
358 help="MiniMax language boost (default: auto)")
359 parser.add_argument("--qwen-api-key-env", default=None,
360 help="environment variable containing the Qwen/DashScope API key")
361 parser.add_argument("--qwen-model", default="qwen3-tts-flash",
362 help="Qwen TTS model ID (default: qwen3-tts-flash)")
363 parser.add_argument("--qwen-base-url", default=None,
364 help="Qwen TTS endpoint or base URL")
365 parser.add_argument("--qwen-language-type", default="Chinese",
366 help="Qwen language_type, e.g. Chinese or English (default: Chinese)")
367 parser.add_argument("--qwen-instructions", default=None,
368 help="optional Qwen instruction text for supported models")
369 parser.add_argument("--qwen-optimize-instructions", action=argparse.BooleanOptionalAction,
370 default=None, help="optionally ask Qwen to optimize instructions")
371 parser.add_argument("--cosyvoice-api-key-env", default="COSYVOICE_API_KEY",
372 help="environment variable containing the CosyVoice/DashScope API key")
373 parser.add_argument("--cosyvoice-model", default="cosyvoice-v3-flash",
374 help="CosyVoice model ID (default: cosyvoice-v3-flash)")
375 parser.add_argument("--cosyvoice-base-url", default=None,
376 help="CosyVoice SpeechSynthesizer endpoint or base URL")
377 parser.add_argument("--cosyvoice-output-format", default="mp3", choices=["mp3", "wav"],
378 help="CosyVoice audio format for PPT narration (default: mp3)")
379 parser.add_argument("--cosyvoice-sample-rate", type=int, default=24000,
380 help="CosyVoice sample rate (default: 24000)")
381 parser.add_argument("--cosyvoice-volume", type=int, default=None,
382 help="optional CosyVoice volume, 0-100")
383 parser.add_argument("--cosyvoice-rate", type=float, default=None,
384 help="optional CosyVoice speaking rate, 0.5-2.0")
385 parser.add_argument("--cosyvoice-pitch", type=float, default=None,
386 help="optional CosyVoice pitch multiplier, 0.5-2.0")
387 parser.add_argument("--cosyvoice-instruction", default=None,
388 help="optional CosyVoice instruction text for supported voices/models")
389 parser.add_argument("--cosyvoice-language-hint", default=None,
390 help="optional CosyVoice language hint, e.g. zh, en, ja")
391 parser.add_argument("--list-common-voices", action="store_true", help="print a curated voice list and exit")
392 parser.add_argument("--list-voices", action="store_true", help="query provider voices and exit")
393 parser.add_argument("--locale", default=None, help='filter --list-voices by locale, e.g. "zh-CN"')
394 args = parser.parse_args()
395
396 if args.list_common_voices:
397 backend_edge.print_common_voices()
398 return 0
399
400 if args.list_voices:
401 try:
402 if args.provider == "elevenlabs":
403 backend_elevenlabs.print_voices(
404 backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
405 )
406 elif args.provider == "minimax":
407 backend_minimax.print_voices()
408 elif args.provider == "qwen":
409 backend_qwen.print_voices()
410 elif args.provider == "cosyvoice":
411 backend_cosyvoice.print_voices()
412 else:
413 asyncio.run(backend_edge.print_voices(args.locale))
414 except Exception as exc:
415 print(f"error: {exc}", file=sys.stderr)
416 return 1
417 return 0
418
419 if args.project_path is None:
420 parser.error("project_path is required unless --list-voices or --list-common-voices is used")
421
422 voice_id = args.voice_id or args.voice
423
424 if args.provider == "edge" and not args.voice:
425 parser.error(
426 "--voice is required for --provider edge. Run --list-voices --locale <locale> to discover voices "
427 "(e.g. --locale zh-CN), or follow skills/ppt-master/workflows/stages/generate-audio.md "
428 "for an AI-curated recommendation."
429 )
430 raise AssertionError("unreachable")
431
432 if args.provider != "edge" and not voice_id:
433 parser.error(f"--voice-id is required for --provider {args.provider}")
434 raise AssertionError("unreachable")
435
436 if args.subtitle_max_chars < 1:
437 parser.error("--subtitle-max-chars must be at least 1")
438 raise AssertionError("unreachable")
439
440 if args.concurrency < 1:
441 parser.error("--concurrency must be at least 1")
442 raise AssertionError("unreachable")
443
444 if args.provider == "elevenlabs":
445 if not voice_id:
446 parser.error("--voice-id is required for --provider elevenlabs")
447 raise AssertionError("unreachable")
448 try:
449 api_key = backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
450 extension = backend_elevenlabs.output_extension(args.elevenlabs_output_format)
451 except Exception as exc:
452 print(f"error: {exc}", file=sys.stderr)
453 return 1
454 backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
455 elif args.provider == "minimax":
456 try:
457 api_key = backend_minimax.read_minimax_api_key(args.minimax_api_key_env)
458 extension = backend_minimax.output_extension(args.minimax_output_format)
459 except Exception as exc:
460 print(f"error: {exc}", file=sys.stderr)
461 return 1
462 backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
463 elif args.provider == "qwen":
464 try:
465 api_key = backend_qwen.read_qwen_api_key(args.qwen_api_key_env)
466 except Exception as exc:
467 print(f"error: {exc}", file=sys.stderr)
468 return 1
469 backend = AudioBackend(
470 provider=args.provider,
471 extension=backend_qwen.output_extension(),
472 api_key=api_key,
473 voice_id=voice_id,
474 )
475 elif args.provider == "cosyvoice":
476 try:
477 api_key = backend_cosyvoice.read_cosyvoice_api_key(args.cosyvoice_api_key_env)
478 extension = backend_cosyvoice.output_extension(args.cosyvoice_output_format)
479 except Exception as exc:
480 print(f"error: {exc}", file=sys.stderr)
481 return 1
482 backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
483 else:
484 backend = AudioBackend(provider=args.provider, extension=backend_edge.edge_output_extension(), voice_id=args.voice)
485
486 project = args.project_path
487 notes_dir = project / "notes"
488 output_dir = args.output or (project / "audio")
489 subtitle_dir = notes_dir / "subtitles"
490
491 try:
492 note_roster = _expected_note_roster(project)
493 if not note_roster:
494 raise ValueError(f"no per-slide notes found in {notes_dir}")
495 jobs = _prepare_audio_jobs(
496 note_roster,
497 output_dir,
498 backend.extension,
499 )
500 except ValueError as exc:
501 print(f"error: {exc}", file=sys.stderr)
502 return 2
503
504 output_dir.mkdir(parents=True, exist_ok=True)
505 if backend.provider == "edge":
506 subtitle_dir.mkdir(parents=True, exist_ok=True)
507
508 generated = 0
509 if backend.provider == "edge":
510 print(
511 f"[Edge] Generating {len(jobs)} audio/SRT pair(s) "
512 f"with concurrency={args.concurrency}"
513 )
514 try:
515 results = asyncio.run(_generate_edge_jobs(
516 jobs,
517 subtitle_dir,
518 voice=args.voice,
519 rate=args.rate,
520 subtitle_max_chars=args.subtitle_max_chars,
521 concurrency=args.concurrency,
522 ))
523 except Exception as exc:
524 print(f"error: Edge audio generation failed: {exc}", file=sys.stderr)
525 return 1
526
527 failed = False
528 for job, result in zip(jobs, results):
529 subtitle_path = subtitle_dir / f"{job.output_path.stem}.srt"
530 if result is not None:
531 print(
532 f"error: failed to generate {job.output_path}: {result}",
533 file=sys.stderr,
534 )
535 failed = True
536 continue
537 try:
538 _remove_stale_audio_variants(job.output_path)
539 except OSError as exc:
540 print(
541 f"error: failed to remove stale audio for "
542 f"{job.output_path.stem}: {exc}",
543 file=sys.stderr,
544 )
545 failed = True
546 continue
547 generated += 1
548 print(f"[OK] {job.output_path}")
549 print(f" {subtitle_path}")
550 if failed:
551 return 1
552 else:
553 for job in jobs:
554 output_path = job.output_path
555 text = job.text
556 try:
557 if backend.provider == "elevenlabs":
558 backend_elevenlabs.generate(
559 text,
560 output_path,
561 api_key=backend.api_key,
562 voice_id=backend.voice_id,
563 model=args.elevenlabs_model,
564 output_format=args.elevenlabs_output_format,
565 stability=args.elevenlabs_stability,
566 similarity_boost=args.elevenlabs_similarity_boost,
567 style=args.elevenlabs_style,
568 speaker_boost=args.elevenlabs_speaker_boost,
569 )
570 elif backend.provider == "minimax":
571 backend_minimax.generate(
572 text,
573 output_path,
574 api_key=backend.api_key,
575 voice_id=backend.voice_id,
576 model=args.minimax_model,
577 audio_format=args.minimax_output_format,
578 sample_rate=args.minimax_sample_rate,
579 bitrate=args.minimax_bitrate,
580 channel=args.minimax_channel,
581 speed=args.minimax_speed,
582 volume=args.minimax_volume,
583 pitch=args.minimax_pitch,
584 language_boost=args.minimax_language_boost,
585 base_url=args.minimax_base_url,
586 )
587 elif backend.provider == "qwen":
588 backend_qwen.generate(
589 text,
590 output_path,
591 api_key=backend.api_key,
592 voice_id=backend.voice_id,
593 model=args.qwen_model,
594 language_type=args.qwen_language_type,
595 instructions=args.qwen_instructions,
596 optimize_instructions=args.qwen_optimize_instructions,
597 base_url=args.qwen_base_url,
598 )
599 elif backend.provider == "cosyvoice":
600 backend_cosyvoice.generate(
601 text,
602 output_path,
603 api_key=backend.api_key,
604 voice_id=backend.voice_id,
605 model=args.cosyvoice_model,
606 audio_format=args.cosyvoice_output_format,
607 sample_rate=args.cosyvoice_sample_rate,
608 volume=args.cosyvoice_volume,
609 rate=args.cosyvoice_rate,
610 pitch=args.cosyvoice_pitch,
611 instruction=args.cosyvoice_instruction,
612 language_hint=args.cosyvoice_language_hint,
613 base_url=args.cosyvoice_base_url,
614 )
615 _remove_stale_audio_variants(output_path)
616 except Exception as exc:
617 print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
618 return 1
619 generated += 1
620 print(f"[OK] {output_path}")
621
622 if backend.provider == "edge":
623 print(
624 f"[Done] Generated {generated}/{len(note_roster)} audio/SRT pair(s): "
625 f"{output_dir} + {subtitle_dir}"
626 )
627 else:
628 print(f"[Done] Generated {generated}/{len(note_roster)} audio file(s): {output_dir}")
629 return 0
630
631
632 if __name__ == "__main__":
633 raise SystemExit(main())
634
634 lines PYTHON