返回 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 MiniMax and CosyVoice request word timings; ElevenLabs requests character
8 alignment. All four apply the same compact, text-faithful cue regrouping.
9 Qwen remains audio-only because its current TTS API exposes no timestamps.
10
11 Usage:
12 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --voice zh-CN-XiaoxiaoNeural
13 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider elevenlabs --voice-id <voice_id>
14 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider minimax --voice-id <voice_id>
15 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider qwen --voice-id <voice>
16 python3 skills/ppt-master/scripts/notes_to_audio.py <project_path> --provider cosyvoice --voice-id <voice>
17 python3 skills/ppt-master/scripts/notes_to_audio.py --list-common-voices
18 python3 skills/ppt-master/scripts/notes_to_audio.py --list-voices --locale zh-CN
19
20 Dependencies:
21 python3 -m pip install edge-tts
22 ELEVENLABS_API_KEY=<key> for --provider elevenlabs
23 MINIMAX_API_KEY=<key> for --provider minimax
24 QWEN_API_KEY or DASHSCOPE_API_KEY=<key> for --provider qwen
25 COSYVOICE_API_KEY or DASHSCOPE_API_KEY=<key> for --provider cosyvoice
26 """
27
28 from __future__ import annotations
29
30 import argparse
31 import asyncio
32 import hashlib
33 import json
34 import os
35 import re
36 import sys
37 from dataclasses import dataclass
38 from datetime import datetime, timezone
39 from pathlib import Path
40
41 from console_encoding import configure_utf8_stdio
42 from config import load_prefixed_env_file
43 from slide_roster import discover_slide_svgs
44 from tts_backends import (
45 backend_cosyvoice,
46 backend_edge,
47 backend_elevenlabs,
48 backend_minimax,
49 backend_qwen,
50 )
51 from tts_backends.backend_common import temporary_path
52
53 configure_utf8_stdio()
54
55 DEFAULT_EDGE_CONCURRENCY = 3
56 SUPPORTED_AUDIO_EXTENSIONS = frozenset({".m4a", ".mp3", ".wav"})
57
58
59 @dataclass(frozen=True)
60 class AudioBackend:
61 provider: str
62 extension: str
63 api_key: str = ""
64 voice_id: str = ""
65
66
67 @dataclass(frozen=True)
68 class NoteRosterEntry:
69 note_path: Path
70 output_stem: str
71
72
73 @dataclass(frozen=True)
74 class AudioJob:
75 note_path: Path
76 text: str
77 output_path: Path
78
79
80 def _load_tts_env_file() -> None:
81 """Load TTS-related keys from the first .env file, without overriding shell env."""
82 load_prefixed_env_file((
83 "ELEVENLABS_",
84 "MINIMAX_",
85 "QWEN_",
86 "DASHSCOPE_",
87 "COSYVOICE_",
88 ))
89
90
91 def spoken_text(markdown: str) -> str:
92 """Return narration text exactly from notes, except Markdown headings."""
93 lines: list[str] = []
94 for raw in markdown.splitlines():
95 if raw.lstrip().startswith("#"):
96 continue
97 line = raw.rstrip()
98 if not line.strip():
99 if lines and lines[-1] != "":
100 lines.append("")
101 continue
102 lines.append(line)
103 return "\n".join(lines).strip()
104
105
106 def _prepare_audio_jobs(
107 note_roster: list[NoteRosterEntry],
108 output_dir: Path,
109 extension: str,
110 ) -> list[AudioJob]:
111 """Read a complete per-slide notes roster into ordered audio jobs."""
112 jobs: list[AudioJob] = []
113 invalid: list[str] = []
114 for entry in note_roster:
115 note_path = entry.note_path
116 if not note_path.is_file():
117 invalid.append(f"{note_path.name} is missing")
118 continue
119 try:
120 text = spoken_text(note_path.read_text(encoding="utf-8"))
121 except (OSError, UnicodeError) as exc:
122 invalid.append(f"{note_path.name} is unreadable: {exc}")
123 continue
124 if not text:
125 invalid.append(f"{note_path.name} has no spoken text")
126 continue
127 jobs.append(AudioJob(
128 note_path=note_path,
129 text=text,
130 output_path=output_dir / f"{entry.output_stem}{extension}",
131 ))
132 if invalid:
133 raise ValueError(
134 "per-slide notes are incomplete: " + "; ".join(invalid)
135 )
136 return jobs
137
138
139 def _expected_note_roster(project: Path) -> list[NoteRosterEntry]:
140 """Resolve the owning route's complete per-slide notes roster."""
141 notes_dir = project / "notes"
142 svg_files = discover_slide_svgs(project / "svg_output")
143 if svg_files:
144 aliases: dict[int, list[Path]] = {}
145 for path in sorted(notes_dir.glob("*.md")):
146 match = re.search(r"slide[_]?(\d+)", path.stem)
147 if match:
148 aliases.setdefault(int(match.group(1)), []).append(path)
149 note_roster: list[NoteRosterEntry] = []
150 for index, svg_path in enumerate(svg_files, 1):
151 exact = notes_dir / f"{svg_path.stem}.md"
152 if exact.exists():
153 note_roster.append(NoteRosterEntry(
154 note_path=exact,
155 output_stem=svg_path.stem,
156 ))
157 continue
158 matches = aliases.get(index, [])
159 if len(matches) > 1:
160 raise ValueError(
161 f"multiple notes files match slide {index}: "
162 + ", ".join(path.name for path in matches)
163 )
164 note_roster.append(
165 NoteRosterEntry(
166 note_path=matches[0] if matches else exact,
167 output_stem=svg_path.stem,
168 )
169 )
170 return note_roster
171
172 slide_index_path = project / "analysis" / "slide_index.json"
173 if slide_index_path.is_file():
174 try:
175 slide_index = json.loads(
176 slide_index_path.read_text(encoding="utf-8")
177 )
178 except (OSError, UnicodeError, json.JSONDecodeError) as exc:
179 raise ValueError(f"invalid slide index: {exc}") from exc
180 if not isinstance(slide_index, dict):
181 raise ValueError("invalid slide index root")
182 slides = slide_index.get("slides")
183 slide_count = slide_index.get("slide_count")
184 if (
185 not isinstance(slides, list)
186 or isinstance(slide_count, bool)
187 or not isinstance(slide_count, int)
188 or slide_count != len(slides)
189 ):
190 raise ValueError("invalid slide index notes roster")
191 note_roster: list[NoteRosterEntry] = []
192 for index, slide in enumerate(slides, 1):
193 note_file = slide.get("note_file") if isinstance(slide, dict) else None
194 if not isinstance(note_file, str) or Path(note_file).suffix != ".md":
195 raise ValueError(
196 f"invalid slide index note_file for slide {index}"
197 )
198 note_name = Path(note_file).name
199 note_roster.append(
200 NoteRosterEntry(
201 note_path=notes_dir / note_name,
202 output_stem=Path(note_name).stem,
203 )
204 )
205 return note_roster
206
207 return [
208 NoteRosterEntry(
209 note_path=path,
210 output_stem=path.stem,
211 )
212 for path in sorted(notes_dir.glob("*.md"))
213 if path.name != "total.md"
214 ]
215
216
217 def _remove_stale_audio_variants(output_path: Path) -> None:
218 """Remove other supported formats only after the target audio is published."""
219 for candidate in output_path.parent.iterdir():
220 if (
221 candidate.name != output_path.name
222 and candidate.is_file()
223 and candidate.stem == output_path.stem
224 and candidate.suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS
225 ):
226 candidate.unlink()
227
228
229 def _sha256_text(value: str) -> str:
230 return hashlib.sha256(value.encode("utf-8")).hexdigest()
231
232
233 def _provider_manifest_details(
234 args: argparse.Namespace,
235 backend: AudioBackend,
236 ) -> tuple[str, dict[str, object]]:
237 if backend.provider == "edge":
238 return "edge-tts", {
239 "rate": args.rate,
240 }
241 if backend.provider == "elevenlabs":
242 return args.elevenlabs_model, {
243 "stability": args.elevenlabs_stability,
244 "similarity_boost": args.elevenlabs_similarity_boost,
245 "style": args.elevenlabs_style,
246 "speed": args.elevenlabs_speed,
247 "speaker_boost": args.elevenlabs_speaker_boost,
248 }
249 if backend.provider == "minimax":
250 return args.minimax_model, {
251 "speed": args.minimax_speed,
252 "volume": args.minimax_volume,
253 "pitch": args.minimax_pitch,
254 "language_boost": args.minimax_language_boost,
255 }
256 if backend.provider == "qwen":
257 return args.qwen_model, {
258 "language_type": args.qwen_language_type,
259 "optimize_instructions": args.qwen_optimize_instructions,
260 "custom_instructions": True if args.qwen_instructions else None,
261 }
262 return args.cosyvoice_model, {
263 "volume": args.cosyvoice_volume,
264 "rate": args.cosyvoice_rate,
265 "pitch": args.cosyvoice_pitch,
266 "language_hint": args.cosyvoice_language_hint,
267 "custom_instruction": True if args.cosyvoice_instruction else None,
268 }
269
270
271 def _narration_manifest(
272 args: argparse.Namespace,
273 backend: AudioBackend,
274 *,
275 writes_subtitles: bool,
276 ) -> dict[str, object]:
277 model, raw_settings = _provider_manifest_details(args, backend)
278 settings = {
279 key: value
280 for key, value in raw_settings.items()
281 if value is not None
282 }
283 voice_ref = backend.voice_id
284 if backend.provider != "edge":
285 voice_ref = f"sha256:{_sha256_text(voice_ref)}"
286
287 manifest: dict[str, object] = {
288 "schema": "ppt-master.narration.v1",
289 "generated_at": datetime.now(timezone.utc).isoformat(
290 timespec="seconds"
291 ).replace("+00:00", "Z"),
292 "provider": backend.provider,
293 "model": model,
294 "voice_ref": voice_ref,
295 "audio_format": backend.extension.lstrip("."),
296 }
297 if settings:
298 manifest["settings"] = settings
299 if writes_subtitles:
300 manifest["subtitles"] = {
301 "format": "srt",
302 "timing": (
303 "character" if backend.provider == "elevenlabs" else "word"
304 ),
305 "max_visible_chars": args.subtitle_max_chars,
306 }
307 return manifest
308
309
310 def _publish_manifest(path: Path, manifest: dict[str, object]) -> None:
311 descriptor, staged_path = temporary_path(path, ".tmp")
312 try:
313 with os.fdopen(
314 descriptor,
315 "w",
316 encoding="utf-8",
317 newline="\n",
318 ) as stream:
319 descriptor = -1
320 json.dump(manifest, stream, ensure_ascii=False, indent=2)
321 stream.write("\n")
322 stream.flush()
323 os.fsync(stream.fileno())
324 os.replace(staged_path, path)
325 finally:
326 if descriptor >= 0:
327 os.close(descriptor)
328 staged_path.unlink(missing_ok=True)
329
330
331 async def _generate_edge_jobs(
332 jobs: list[AudioJob],
333 subtitle_dir: Path,
334 *,
335 voice: str,
336 rate: str,
337 subtitle_max_chars: int,
338 concurrency: int,
339 ) -> list[BaseException | None]:
340 """Generate ordered Edge jobs with bounded slide-level concurrency."""
341 semaphore = asyncio.Semaphore(concurrency)
342
343 async def generate_job(job: AudioJob) -> None:
344 async with semaphore:
345 await backend_edge.generate(
346 job.text,
347 job.output_path,
348 voice=voice,
349 rate=rate,
350 subtitle_path=subtitle_dir / f"{job.output_path.stem}.srt",
351 subtitle_max_chars=subtitle_max_chars,
352 )
353
354 raw_results = await asyncio.gather(
355 *(generate_job(job) for job in jobs),
356 return_exceptions=True,
357 )
358 return [
359 result if isinstance(result, BaseException) else None
360 for result in raw_results
361 ]
362
363
364 def main() -> int:
365 _load_tts_env_file()
366
367 parser = argparse.ArgumentParser(
368 description=__doc__,
369 formatter_class=argparse.RawDescriptionHelpFormatter,
370 )
371 parser.add_argument("project_path", type=Path, nargs="?")
372 parser.add_argument("-o", "--output", type=Path, default=None)
373 parser.add_argument(
374 "--provider",
375 choices=["edge", "elevenlabs", "minimax", "qwen", "cosyvoice"],
376 default="edge",
377 help="audio generation backend (default: edge)",
378 )
379 parser.add_argument(
380 "--voice",
381 default=None,
382 help="edge-tts voice ShortName. For elevenlabs, --voice-id is preferred.",
383 )
384 parser.add_argument(
385 "--voice-id",
386 default=None,
387 help="provider voice ID/name. If omitted for cloud providers, --voice is used as a fallback.",
388 )
389 parser.add_argument(
390 "--rate",
391 default="+0%",
392 help='edge-tts speaking rate, e.g. "+0%%", "-10%%", "+15%%" (default: +0%%). Ignored by cloud providers.',
393 )
394 parser.add_argument(
395 "--concurrency",
396 type=int,
397 default=DEFAULT_EDGE_CONCURRENCY,
398 help="maximum concurrent Edge slide requests (default: 3; ignored by cloud providers)",
399 )
400 parser.add_argument(
401 "--subtitle-max-chars",
402 type=int,
403 default=backend_edge.DEFAULT_SUBTITLE_MAX_CHARS,
404 help="maximum visible characters per provider-timed subtitle cue (default: 20)",
405 )
406 parser.add_argument(
407 "--elevenlabs-api-key-env",
408 default="ELEVENLABS_API_KEY",
409 help="environment variable containing the ElevenLabs API key (default: ELEVENLABS_API_KEY)",
410 )
411 parser.add_argument(
412 "--elevenlabs-model",
413 default="eleven_multilingual_v2",
414 help="ElevenLabs TTS model ID (default: eleven_multilingual_v2)",
415 )
416 parser.add_argument(
417 "--elevenlabs-output-format",
418 default="mp3_44100_128",
419 help="ElevenLabs output format (default: mp3_44100_128)",
420 )
421 parser.add_argument(
422 "--elevenlabs-stability",
423 type=float,
424 default=None,
425 help="optional ElevenLabs voice stability override, 0.0-1.0",
426 )
427 parser.add_argument(
428 "--elevenlabs-similarity-boost",
429 type=float,
430 default=None,
431 help="optional ElevenLabs similarity boost override, 0.0-1.0",
432 )
433 parser.add_argument(
434 "--elevenlabs-style",
435 type=float,
436 default=None,
437 help="optional ElevenLabs style exaggeration override, 0.0-1.0",
438 )
439 parser.add_argument(
440 "--elevenlabs-speed",
441 type=float,
442 default=None,
443 help="optional ElevenLabs speaking speed override, 0.7-1.2",
444 )
445 parser.add_argument(
446 "--elevenlabs-speaker-boost",
447 action=argparse.BooleanOptionalAction,
448 default=None,
449 help="optionally override ElevenLabs speaker boost",
450 )
451 parser.add_argument("--minimax-api-key-env", default="MINIMAX_API_KEY",
452 help="environment variable containing the MiniMax API key")
453 parser.add_argument("--minimax-model", default="speech-2.8-hd",
454 help="MiniMax T2A model ID (default: speech-2.8-hd)")
455 parser.add_argument("--minimax-base-url", default=None,
456 help="MiniMax T2A endpoint or base URL")
457 parser.add_argument("--minimax-output-format", default="mp3", choices=["mp3", "wav"],
458 help="MiniMax audio format for PPT narration (default: mp3)")
459 parser.add_argument("--minimax-sample-rate", type=int, default=32000,
460 help="MiniMax sample rate (default: 32000)")
461 parser.add_argument("--minimax-bitrate", type=int, default=128000,
462 help="MiniMax bitrate in bps (default: 128000)")
463 parser.add_argument("--minimax-channel", type=int, default=1,
464 help="MiniMax channel count (default: 1)")
465 parser.add_argument("--minimax-speed", type=float, default=1.0,
466 help="MiniMax speaking speed (default: 1.0)")
467 parser.add_argument("--minimax-volume", type=float, default=1.0,
468 help="MiniMax volume multiplier (default: 1.0)")
469 parser.add_argument("--minimax-pitch", type=int, default=0,
470 help="MiniMax pitch adjustment (default: 0)")
471 parser.add_argument("--minimax-language-boost", default="auto",
472 help="MiniMax language boost (default: auto)")
473 parser.add_argument("--qwen-api-key-env", default=None,
474 help="environment variable containing the Qwen/DashScope API key")
475 parser.add_argument("--qwen-model", default="qwen3-tts-flash",
476 help="Qwen TTS model ID (default: qwen3-tts-flash)")
477 parser.add_argument("--qwen-base-url", default=None,
478 help="Qwen TTS endpoint or base URL")
479 parser.add_argument("--qwen-language-type", default="Chinese",
480 help="Qwen language_type, e.g. Chinese or English (default: Chinese)")
481 parser.add_argument("--qwen-instructions", default=None,
482 help="optional Qwen instruction text for supported models")
483 parser.add_argument("--qwen-optimize-instructions", action=argparse.BooleanOptionalAction,
484 default=None, help="optionally ask Qwen to optimize instructions")
485 parser.add_argument("--cosyvoice-api-key-env", default="COSYVOICE_API_KEY",
486 help="environment variable containing the CosyVoice/DashScope API key")
487 parser.add_argument("--cosyvoice-model", default="cosyvoice-v3-flash",
488 help="CosyVoice model ID (default: cosyvoice-v3-flash)")
489 parser.add_argument("--cosyvoice-base-url", default=None,
490 help="CosyVoice SpeechSynthesizer endpoint or base URL")
491 parser.add_argument("--cosyvoice-output-format", default="mp3", choices=["mp3", "wav"],
492 help="CosyVoice audio format for PPT narration (default: mp3)")
493 parser.add_argument("--cosyvoice-sample-rate", type=int, default=24000,
494 choices=[8000, 16000, 22050, 24000, 44100, 48000],
495 help="CosyVoice sample rate (default: 24000)")
496 parser.add_argument("--cosyvoice-volume", type=int, default=None,
497 help="optional CosyVoice volume, 0-100")
498 parser.add_argument("--cosyvoice-rate", type=float, default=None,
499 help="optional CosyVoice speaking rate, 0.5-2.0")
500 parser.add_argument("--cosyvoice-pitch", type=float, default=None,
501 help="optional CosyVoice pitch multiplier, 0.5-2.0")
502 parser.add_argument("--cosyvoice-instruction", default=None,
503 help="optional CosyVoice instruction text for supported voices/models")
504 parser.add_argument("--cosyvoice-language-hint", default=None,
505 help="optional CosyVoice language hint, e.g. zh, en, ja")
506 parser.add_argument(
507 "--cosyvoice-audio-only",
508 action="store_true",
509 help=(
510 "skip CosyVoice timestamps/SRT for a model or voice that does not "
511 "support them"
512 ),
513 )
514 parser.add_argument("--list-common-voices", action="store_true", help="print a curated voice list and exit")
515 parser.add_argument("--list-voices", action="store_true", help="query provider voices and exit")
516 parser.add_argument("--locale", default=None, help='filter --list-voices by locale, e.g. "zh-CN"')
517 args = parser.parse_args()
518
519 if args.list_common_voices:
520 backend_edge.print_common_voices()
521 return 0
522
523 if args.list_voices:
524 try:
525 if args.provider == "elevenlabs":
526 backend_elevenlabs.print_voices(
527 backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
528 )
529 elif args.provider == "minimax":
530 backend_minimax.print_voices()
531 elif args.provider == "qwen":
532 backend_qwen.print_voices()
533 elif args.provider == "cosyvoice":
534 backend_cosyvoice.print_voices()
535 else:
536 asyncio.run(backend_edge.print_voices(args.locale))
537 except Exception as exc:
538 print(f"error: {exc}", file=sys.stderr)
539 return 1
540 return 0
541
542 if args.project_path is None:
543 parser.error("project_path is required unless --list-voices or --list-common-voices is used")
544
545 voice_id = args.voice_id or args.voice
546
547 if args.provider == "edge" and not args.voice:
548 parser.error(
549 "--voice is required for --provider edge. Run --list-voices --locale <locale> to discover voices "
550 "(e.g. --locale zh-CN), or follow skills/ppt-master/workflows/stages/generate-audio.md "
551 "for an AI-curated recommendation."
552 )
553 raise AssertionError("unreachable")
554
555 if args.provider != "edge" and not voice_id:
556 parser.error(f"--voice-id is required for --provider {args.provider}")
557 raise AssertionError("unreachable")
558
559 if args.subtitle_max_chars < 1:
560 parser.error("--subtitle-max-chars must be at least 1")
561 raise AssertionError("unreachable")
562
563 if args.concurrency < 1:
564 parser.error("--concurrency must be at least 1")
565 raise AssertionError("unreachable")
566
567 for option, value, minimum, maximum in (
568 ("--elevenlabs-stability", args.elevenlabs_stability, 0.0, 1.0),
569 (
570 "--elevenlabs-similarity-boost",
571 args.elevenlabs_similarity_boost,
572 0.0,
573 1.0,
574 ),
575 ("--elevenlabs-style", args.elevenlabs_style, 0.0, 1.0),
576 ("--elevenlabs-speed", args.elevenlabs_speed, 0.7, 1.2),
577 ("--cosyvoice-volume", args.cosyvoice_volume, 0, 100),
578 ("--cosyvoice-rate", args.cosyvoice_rate, 0.5, 2.0),
579 ("--cosyvoice-pitch", args.cosyvoice_pitch, 0.5, 2.0),
580 ):
581 if value is not None and not minimum <= value <= maximum:
582 parser.error(f"{option} must be between {minimum} and {maximum}")
583 raise AssertionError("unreachable")
584
585 if args.cosyvoice_audio_only and args.provider != "cosyvoice":
586 parser.error("--cosyvoice-audio-only requires --provider cosyvoice")
587 raise AssertionError("unreachable")
588
589 if args.provider == "qwen" and args.qwen_instructions:
590 if "instruct" not in args.qwen_model:
591 parser.error(
592 "--qwen-instructions requires a Qwen3 TTS Instruct model"
593 )
594 raise AssertionError("unreachable")
595 if args.qwen_optimize_instructions and not args.qwen_instructions:
596 parser.error(
597 "--qwen-optimize-instructions requires --qwen-instructions"
598 )
599 raise AssertionError("unreachable")
600
601 if args.provider == "elevenlabs":
602 if not voice_id:
603 parser.error("--voice-id is required for --provider elevenlabs")
604 raise AssertionError("unreachable")
605 try:
606 api_key = backend_elevenlabs.read_elevenlabs_api_key(args.elevenlabs_api_key_env)
607 extension = backend_elevenlabs.output_extension(args.elevenlabs_output_format)
608 except Exception as exc:
609 print(f"error: {exc}", file=sys.stderr)
610 return 1
611 backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
612 elif args.provider == "minimax":
613 try:
614 api_key = backend_minimax.read_minimax_api_key(args.minimax_api_key_env)
615 extension = backend_minimax.output_extension(args.minimax_output_format)
616 except Exception as exc:
617 print(f"error: {exc}", file=sys.stderr)
618 return 1
619 backend = AudioBackend(provider=args.provider, extension=extension, api_key=api_key, voice_id=voice_id)
620 elif args.provider == "qwen":
621 try:
622 api_key = backend_qwen.read_qwen_api_key(args.qwen_api_key_env)
623 except Exception as exc:
624 print(f"error: {exc}", file=sys.stderr)
625 return 1
626 backend = AudioBackend(
627 provider=args.provider,
628 extension=backend_qwen.output_extension(),
629 api_key=api_key,
630 voice_id=voice_id,
631 )
632 elif args.provider == "cosyvoice":
633 try:
634 api_key = backend_cosyvoice.read_cosyvoice_api_key(args.cosyvoice_api_key_env)
635 extension = backend_cosyvoice.output_extension(args.cosyvoice_output_format)
636 except Exception as exc:
637 print(f"error: {exc}", file=sys.stderr)
638 return 1
639 backend = AudioBackend(
640 provider=args.provider,
641 extension=extension,
642 api_key=api_key,
643 voice_id=voice_id,
644 )
645 else:
646 backend = AudioBackend(
647 provider=args.provider,
648 extension=backend_edge.edge_output_extension(),
649 voice_id=args.voice,
650 )
651
652 project = args.project_path
653 notes_dir = project / "notes"
654 output_dir = args.output or (project / "audio")
655 subtitle_dir = output_dir
656 writes_subtitles = backend.provider in {
657 "edge",
658 "elevenlabs",
659 "minimax",
660 } or (
661 backend.provider == "cosyvoice"
662 and not args.cosyvoice_audio_only
663 )
664
665 try:
666 note_roster = _expected_note_roster(project)
667 if not note_roster:
668 raise ValueError(f"no per-slide notes found in {notes_dir}")
669 jobs = _prepare_audio_jobs(
670 note_roster,
671 output_dir,
672 backend.extension,
673 )
674 except ValueError as exc:
675 print(f"error: {exc}", file=sys.stderr)
676 return 2
677
678 output_dir.mkdir(parents=True, exist_ok=True)
679 manifest_path = output_dir / "manifest.json"
680 try:
681 manifest_path.unlink(missing_ok=True)
682 (output_dir / "total.srt").unlink(missing_ok=True)
683 except OSError as exc:
684 print(f"error: failed to clear stale narration metadata: {exc}", file=sys.stderr)
685 return 1
686
687 generated = 0
688 if backend.provider == "edge":
689 print(
690 f"[Edge] Generating {len(jobs)} audio/SRT pair(s) "
691 f"with concurrency={args.concurrency}"
692 )
693 try:
694 results = asyncio.run(_generate_edge_jobs(
695 jobs,
696 subtitle_dir,
697 voice=args.voice,
698 rate=args.rate,
699 subtitle_max_chars=args.subtitle_max_chars,
700 concurrency=args.concurrency,
701 ))
702 except Exception as exc:
703 print(f"error: Edge audio generation failed: {exc}", file=sys.stderr)
704 return 1
705
706 failed = False
707 for job, result in zip(jobs, results):
708 subtitle_path = subtitle_dir / f"{job.output_path.stem}.srt"
709 if result is not None:
710 print(
711 f"error: failed to generate {job.output_path}: {result}",
712 file=sys.stderr,
713 )
714 failed = True
715 continue
716 try:
717 _remove_stale_audio_variants(job.output_path)
718 except OSError as exc:
719 print(
720 f"error: failed to remove stale audio for "
721 f"{job.output_path.stem}: {exc}",
722 file=sys.stderr,
723 )
724 failed = True
725 continue
726 generated += 1
727 print(f"[OK] {job.output_path}")
728 print(f" {subtitle_path}")
729 if failed:
730 return 1
731 else:
732 for job in jobs:
733 output_path = job.output_path
734 text = job.text
735 subtitle_path: Path | None = None
736 try:
737 if backend.provider == "elevenlabs":
738 if writes_subtitles:
739 subtitle_path = subtitle_dir / f"{output_path.stem}.srt"
740 backend_elevenlabs.generate(
741 text,
742 output_path,
743 api_key=backend.api_key,
744 voice_id=backend.voice_id,
745 model=args.elevenlabs_model,
746 output_format=args.elevenlabs_output_format,
747 stability=args.elevenlabs_stability,
748 similarity_boost=args.elevenlabs_similarity_boost,
749 style=args.elevenlabs_style,
750 speed=args.elevenlabs_speed,
751 speaker_boost=args.elevenlabs_speaker_boost,
752 subtitle_path=subtitle_path,
753 subtitle_max_chars=args.subtitle_max_chars,
754 )
755 elif backend.provider == "minimax":
756 if writes_subtitles:
757 subtitle_path = subtitle_dir / f"{output_path.stem}.srt"
758 backend_minimax.generate(
759 text,
760 output_path,
761 api_key=backend.api_key,
762 voice_id=backend.voice_id,
763 model=args.minimax_model,
764 audio_format=args.minimax_output_format,
765 sample_rate=args.minimax_sample_rate,
766 bitrate=args.minimax_bitrate,
767 channel=args.minimax_channel,
768 speed=args.minimax_speed,
769 volume=args.minimax_volume,
770 pitch=args.minimax_pitch,
771 language_boost=args.minimax_language_boost,
772 base_url=args.minimax_base_url,
773 subtitle_path=subtitle_path,
774 subtitle_max_chars=args.subtitle_max_chars,
775 )
776 elif backend.provider == "qwen":
777 backend_qwen.generate(
778 text,
779 output_path,
780 api_key=backend.api_key,
781 voice_id=backend.voice_id,
782 model=args.qwen_model,
783 language_type=args.qwen_language_type,
784 instructions=args.qwen_instructions,
785 optimize_instructions=args.qwen_optimize_instructions,
786 base_url=args.qwen_base_url,
787 )
788 elif backend.provider == "cosyvoice":
789 if writes_subtitles:
790 subtitle_path = subtitle_dir / f"{output_path.stem}.srt"
791 backend_cosyvoice.generate(
792 text,
793 output_path,
794 api_key=backend.api_key,
795 voice_id=backend.voice_id,
796 model=args.cosyvoice_model,
797 audio_format=args.cosyvoice_output_format,
798 sample_rate=args.cosyvoice_sample_rate,
799 volume=args.cosyvoice_volume,
800 rate=args.cosyvoice_rate,
801 pitch=args.cosyvoice_pitch,
802 instruction=args.cosyvoice_instruction,
803 language_hint=args.cosyvoice_language_hint,
804 base_url=args.cosyvoice_base_url,
805 subtitle_path=subtitle_path,
806 subtitle_max_chars=args.subtitle_max_chars,
807 )
808 _remove_stale_audio_variants(output_path)
809 if not writes_subtitles:
810 output_path.with_suffix(".srt").unlink(missing_ok=True)
811 except Exception as exc:
812 print(f"error: failed to generate {output_path}: {exc}", file=sys.stderr)
813 return 1
814 generated += 1
815 print(f"[OK] {output_path}")
816 if subtitle_path is not None:
817 print(f" {subtitle_path}")
818
819 try:
820 _publish_manifest(
821 manifest_path,
822 _narration_manifest(
823 args,
824 backend,
825 writes_subtitles=writes_subtitles,
826 ),
827 )
828 except (OSError, RuntimeError) as exc:
829 print(f"error: failed to publish narration manifest: {exc}", file=sys.stderr)
830 return 1
831
832 if writes_subtitles:
833 print(
834 f"[Done] Generated {generated}/{len(note_roster)} audio/SRT pair(s): "
835 f"{output_dir}"
836 )
837 else:
838 print(f"[Done] Generated {generated}/{len(note_roster)} audio file(s): {output_dir}")
839 print(f"[REPORT] Narration manifest: {manifest_path}")
840 return 0
841
842
843 if __name__ == "__main__":
844 raise SystemExit(main())
845
845 lines PYTHON