microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/transparency-note

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

.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
6Reads slide-NNN.wav files from an audio directory and adds them as embedded
7media objects in the corresponding slides of a PPTX file. Adds animation
8timing XML so PowerPoint recognizes the audio as narrations, enabling
9'Use Recorded Timings and Narrations' in File > Export > Create a Video.
10
11Usage:
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
17from __future__ import annotations
18
19import argparse
20import logging
21import sys
22import wave
23from pathlib import Path
24
25from lxml import etree
26from pptx import Presentation
27from pptx.oxml.ns import qn
28from pptx.slide import Slide
29from pptx.util import Inches
30
31logger = logging.getLogger(__name__)
32
33EXIT_SUCCESS = 0
34EXIT_FAILURE = 1
35EXIT_ERROR = 2
36
37AUDIO_MIME_TYPE = "audio/wav"
38ICON_SIZE = Inches(0.1)
39TIMING_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
73def 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
88def _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
138def _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
166def 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
200def 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
232def 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
238def _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
317def 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
331if __name__ == "__main__":
332 sys.exit(main())
333