microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
.github/skills/experimental/tts-voiceover/scripts/embed_audio.py
332lines · modecode
| 1 | #!/usr/bin/env python3 |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | """Embed per-slide WAV voice-over files into a PowerPoint deck. |
| 5 | |
| 6 | Reads slide-NNN.wav files from an audio directory and adds them as embedded |
| 7 | media objects in the corresponding slides of a PPTX file. Adds animation |
| 8 | timing XML so PowerPoint recognizes the audio as narrations, enabling |
| 9 | 'Use Recorded Timings and Narrations' in File > Export > Create a Video. |
| 10 | |
| 11 | Usage: |
| 12 | python embed_audio.py --input deck.pptx --audio-dir voice-over |
| 13 | python embed_audio.py --input deck.pptx --audio-dir voice-over \ |
| 14 | --output deck-narrated.pptx |
| 15 | """ |
| 16 | |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import argparse |
| 20 | import logging |
| 21 | import sys |
| 22 | import wave |
| 23 | from pathlib import Path |
| 24 | |
| 25 | from lxml import etree |
| 26 | from pptx import Presentation |
| 27 | from pptx.oxml.ns import qn |
| 28 | from pptx.slide import Slide |
| 29 | from pptx.util import Inches |
| 30 | |
| 31 | logger = logging.getLogger(__name__) |
| 32 | |
| 33 | EXIT_SUCCESS = 0 |
| 34 | EXIT_FAILURE = 1 |
| 35 | EXIT_ERROR = 2 |
| 36 | |
| 37 | AUDIO_MIME_TYPE = "audio/wav" |
| 38 | ICON_SIZE = Inches(0.1) |
| 39 | TIMING_BUFFER_MS = 1500 |
| 40 | |
| 41 | _PPTX_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 42 | _TIMING_TEMPLATE = ( |
| 43 | f'<p:timing xmlns:p="{_PPTX_NS}">' |
| 44 | "<p:tnLst><p:par>" |
| 45 | '<p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">' |
| 46 | "<p:childTnLst>" |
| 47 | '<p:seq concurrent="1" nextAc="seek">' |
| 48 | '<p:cTn id="2" dur="indefinite" nodeType="mainSeq">' |
| 49 | "<p:childTnLst><p:par>" |
| 50 | '<p:cTn id="3" fill="hold">' |
| 51 | '<p:stCondLst><p:cond delay="0"/></p:stCondLst>' |
| 52 | "<p:childTnLst><p:par>" |
| 53 | '<p:cTn id="4" fill="hold">' |
| 54 | '<p:stCondLst><p:cond delay="0"/></p:stCondLst>' |
| 55 | "<p:childTnLst>" |
| 56 | '<p:cmd type="call" cmd="playFrom(0)"><p:cBhvr>' |
| 57 | '<p:cTn id="5" dur="0" fill="hold"/>' |
| 58 | '<p:tgtEl><p:spTgt spid="0"/></p:tgtEl>' |
| 59 | "</p:cBhvr></p:cmd>" |
| 60 | "</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>" |
| 61 | "</p:childTnLst></p:cTn>" |
| 62 | "<p:prevCondLst>" |
| 63 | '<p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond>' |
| 64 | "</p:prevCondLst>" |
| 65 | "<p:nextCondLst>" |
| 66 | '<p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond>' |
| 67 | "</p:nextCondLst>" |
| 68 | "</p:seq></p:childTnLst></p:cTn>" |
| 69 | "</p:par></p:tnLst></p:timing>" |
| 70 | ) |
| 71 | |
| 72 | |
| 73 | def get_wav_duration_ms(wav_path: Path) -> int: |
| 74 | """Return WAV file duration in milliseconds with buffer. |
| 75 | |
| 76 | Args: |
| 77 | wav_path: Path to the WAV audio file. |
| 78 | |
| 79 | Returns: |
| 80 | Duration in milliseconds plus ``TIMING_BUFFER_MS``. |
| 81 | """ |
| 82 | with wave.open(str(wav_path), "rb") as wf: |
| 83 | frames = wf.getnframes() |
| 84 | rate = wf.getframerate() |
| 85 | return int((frames / float(rate)) * 1000) + TIMING_BUFFER_MS |
| 86 | |
| 87 | |
| 88 | def _add_narration_timing(slide: Slide, shape_id: int, duration_ms: int) -> None: |
| 89 | """Add auto-play narration timing XML to a slide. |
| 90 | |
| 91 | Creates the ``p:timing`` element structure that PowerPoint generates |
| 92 | when using Record Slide Show, enabling 'Use Recorded Timings and |
| 93 | Narrations' in video export. |
| 94 | |
| 95 | Args: |
| 96 | slide: The slide to modify. |
| 97 | shape_id: The ``spId`` of the embedded audio shape. |
| 98 | duration_ms: Audio duration in milliseconds. |
| 99 | """ |
| 100 | existing = slide._element.find(qn("p:timing")) |
| 101 | if existing is not None: |
| 102 | # Warn whenever an existing timing element is replaced, since any |
| 103 | # authored animation (entrance effect, click sequence) produces at |
| 104 | # least one p:seq that will be overwritten. |
| 105 | child_seqs = existing.findall(f".//{qn('p:seq')}") |
| 106 | if child_seqs: |
| 107 | logger.warning( |
| 108 | "Replacing existing slide timing (%d sequence(s)) for shape %d; " |
| 109 | "authored animations on this slide will be overwritten.", |
| 110 | len(child_seqs), |
| 111 | shape_id, |
| 112 | ) |
| 113 | slide._element.remove(existing) |
| 114 | |
| 115 | timing = etree.fromstring(_TIMING_TEMPLATE) |
| 116 | ns = {"p": _PPTX_NS} |
| 117 | sp_tgt = timing.find(".//p:spTgt", ns) |
| 118 | if sp_tgt is not None: |
| 119 | sp_tgt.set("spid", str(shape_id)) |
| 120 | else: |
| 121 | logger.warning( |
| 122 | "spTgt element not found in timing template for shape %d; " |
| 123 | "audio shape link will be missing.", |
| 124 | shape_id, |
| 125 | ) |
| 126 | ctn_dur = timing.find(".//p:cTn[@id='5']", ns) |
| 127 | if ctn_dur is not None: |
| 128 | ctn_dur.set("dur", str(duration_ms)) |
| 129 | else: |
| 130 | logger.warning( |
| 131 | "cTn[@id='5'] not found in timing template for shape %d; " |
| 132 | "audio duration will be unset.", |
| 133 | shape_id, |
| 134 | ) |
| 135 | slide._element.append(timing) |
| 136 | |
| 137 | |
| 138 | def _set_slide_transition(slide: Slide, duration_ms: int) -> None: |
| 139 | """Set slide auto-advance timing after audio duration. |
| 140 | |
| 141 | Sets ``advClick="0"`` so slides advance only on the audio timer, |
| 142 | not on manual click. To re-enable click-to-advance after embedding, |
| 143 | use the Transitions tab in PowerPoint. |
| 144 | |
| 145 | Args: |
| 146 | slide: The slide to modify. |
| 147 | duration_ms: Auto-advance delay in milliseconds. |
| 148 | """ |
| 149 | existing = slide._element.find(qn("p:transition")) |
| 150 | if existing is not None: |
| 151 | slide._element.remove(existing) |
| 152 | |
| 153 | # advClick="0" prevents accidental click-to-skip during audio playback; |
| 154 | # slides advance only when the audio timer expires. |
| 155 | transition = slide._element.makeelement( |
| 156 | qn("p:transition"), |
| 157 | {"advClick": "0", "advTm": str(duration_ms)}, |
| 158 | ) |
| 159 | timing = slide._element.find(qn("p:timing")) |
| 160 | if timing is not None: |
| 161 | timing.addprevious(transition) |
| 162 | else: |
| 163 | slide._element.append(transition) |
| 164 | |
| 165 | |
| 166 | def embed_slide_audio(slide: Slide, wav_path: Path) -> bool: |
| 167 | """Embed a WAV file into a slide as a media object. |
| 168 | |
| 169 | Adds narration timing XML and slide auto-advance so PowerPoint |
| 170 | recognizes the audio for video export. |
| 171 | |
| 172 | Args: |
| 173 | slide: The target slide. |
| 174 | wav_path: Path to the WAV audio file to embed. |
| 175 | |
| 176 | Returns: |
| 177 | ``True`` on success, ``False`` on failure. |
| 178 | """ |
| 179 | try: |
| 180 | movie_shape = slide.shapes.add_movie( |
| 181 | str(wav_path), |
| 182 | left=0, |
| 183 | top=0, |
| 184 | width=ICON_SIZE, |
| 185 | height=ICON_SIZE, |
| 186 | mime_type=AUDIO_MIME_TYPE, |
| 187 | ) |
| 188 | shape_id: int = movie_shape.shape_id |
| 189 | duration_ms = get_wav_duration_ms(wav_path) |
| 190 | _add_narration_timing(slide, shape_id, duration_ms) |
| 191 | _set_slide_transition(slide, duration_ms) |
| 192 | return True |
| 193 | except Exception as exc: # python-pptx raises varied internal exceptions |
| 194 | logger.exception( |
| 195 | "Failed to embed audio %s (%s)", wav_path.name, type(exc).__name__ |
| 196 | ) |
| 197 | return False |
| 198 | |
| 199 | |
| 200 | def create_parser() -> argparse.ArgumentParser: |
| 201 | """Create and configure the argument parser.""" |
| 202 | parser = argparse.ArgumentParser( |
| 203 | description="Embed per-slide WAV voice-over files into a PPTX deck" |
| 204 | ) |
| 205 | parser.add_argument( |
| 206 | "--input", |
| 207 | type=Path, |
| 208 | required=True, |
| 209 | help="Source PPTX file path", |
| 210 | ) |
| 211 | parser.add_argument( |
| 212 | "--audio-dir", |
| 213 | type=Path, |
| 214 | default=Path("voice-over"), |
| 215 | help="Directory containing slide-NNN.wav files (default: voice-over)", |
| 216 | ) |
| 217 | parser.add_argument( |
| 218 | "--output", |
| 219 | type=Path, |
| 220 | default=None, |
| 221 | help="Output PPTX file path (default: input stem + '-narrated.pptx')", |
| 222 | ) |
| 223 | parser.add_argument( |
| 224 | "-v", |
| 225 | "--verbose", |
| 226 | action="store_true", |
| 227 | help="Enable verbose (DEBUG) logging", |
| 228 | ) |
| 229 | return parser |
| 230 | |
| 231 | |
| 232 | def configure_logging(verbose: bool = False) -> None: |
| 233 | """Configure logging based on verbosity level.""" |
| 234 | level = logging.DEBUG if verbose else logging.INFO |
| 235 | logging.basicConfig(level=level, format="%(levelname)s: %(message)s") |
| 236 | |
| 237 | |
| 238 | def _run(args: argparse.Namespace) -> int: |
| 239 | """Execute audio embedding logic.""" |
| 240 | |
| 241 | input_path: Path = args.input |
| 242 | audio_dir: Path = args.audio_dir |
| 243 | |
| 244 | if not input_path.is_file(): |
| 245 | logger.error("Input PPTX not found: %s", input_path) |
| 246 | return EXIT_FAILURE |
| 247 | |
| 248 | if not audio_dir.is_dir(): |
| 249 | logger.error("Audio directory not found: %s", audio_dir) |
| 250 | return EXIT_FAILURE |
| 251 | |
| 252 | output_path: Path = args.output or input_path.with_name( |
| 253 | f"{input_path.stem}-narrated.pptx" |
| 254 | ) |
| 255 | |
| 256 | if output_path.resolve() == input_path.resolve(): |
| 257 | logger.error( |
| 258 | "Output path must differ from input path to avoid overwriting the source" |
| 259 | ) |
| 260 | return EXIT_ERROR |
| 261 | |
| 262 | prs = Presentation(str(input_path)) |
| 263 | embedded_count = 0 |
| 264 | failed_count = 0 |
| 265 | |
| 266 | # Build a mapping from slide number to WAV path so embedding matches |
| 267 | # the directory names used by generate_voiceover.py rather than |
| 268 | # re-deriving names from the enumerate index. |
| 269 | wav_files: dict[int, Path] = {} |
| 270 | for wav in sorted(audio_dir.glob("slide-*.wav")): |
| 271 | try: |
| 272 | num = int(wav.stem.split("-")[1]) |
| 273 | wav_files[num] = wav |
| 274 | except (IndexError, ValueError): |
| 275 | logger.warning("Ignoring unexpected file: %s", wav.name) |
| 276 | |
| 277 | for idx, slide in enumerate(prs.slides, start=1): |
| 278 | wav_path = wav_files.get(idx) |
| 279 | if wav_path is None: |
| 280 | logger.info("SKIP slide %d: no WAV found", idx) |
| 281 | continue |
| 282 | |
| 283 | if embed_slide_audio(slide, wav_path): |
| 284 | embedded_count += 1 |
| 285 | logger.info("Embedded %s into slide %d", wav_path.name, idx) |
| 286 | else: |
| 287 | logger.error("FAILED to embed %s into slide %d", wav_path.name, idx) |
| 288 | failed_count += 1 |
| 289 | |
| 290 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 291 | |
| 292 | if embedded_count == 0: |
| 293 | logger.error( |
| 294 | "No audio files were embedded. Verify that slide-NNN.wav files exist in %s", |
| 295 | audio_dir, |
| 296 | ) |
| 297 | return EXIT_FAILURE |
| 298 | |
| 299 | try: |
| 300 | prs.save(str(output_path)) |
| 301 | except OSError as exc: |
| 302 | logger.error("Failed to save output PPTX %s: %s", output_path, exc) |
| 303 | return EXIT_FAILURE |
| 304 | |
| 305 | logger.info("Saved %s with %d embedded audio files", output_path, embedded_count) |
| 306 | |
| 307 | if failed_count > 0: |
| 308 | logger.warning( |
| 309 | "Completed with %d failure(s); %d slide(s) embedded successfully.", |
| 310 | failed_count, |
| 311 | embedded_count, |
| 312 | ) |
| 313 | return EXIT_FAILURE |
| 314 | return EXIT_SUCCESS |
| 315 | |
| 316 | |
| 317 | def main() -> int: |
| 318 | """Entry point for audio embedding.""" |
| 319 | parser = create_parser() |
| 320 | args = parser.parse_args() |
| 321 | configure_logging(verbose=args.verbose) |
| 322 | try: |
| 323 | return _run(args) |
| 324 | except KeyboardInterrupt: |
| 325 | return 130 |
| 326 | except BrokenPipeError: |
| 327 | sys.stderr.close() |
| 328 | return 1 |
| 329 | |
| 330 | |
| 331 | if __name__ == "__main__": |
| 332 | sys.exit(main()) |
| 333 | |