返回 ppt-master
sound_sync.py
根目录 / skills / ppt-master / scripts / sound_sync.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Sound Sync
4
5 Inspect the bundled CC0 sound catalog or copy explicitly selected sounds into a
6 project-local `sounds/` directory. Review `templates/sounds/sound-vocabulary.md`
7 before using list filters for a resolved auditory job. The global library is
8 never copied in bulk.
9
10 Usage:
11 python3 scripts/sound_sync.py list [--query TERM]
12 python3 scripts/sound_sync.py <project_path> <sound_id> [<sound_id> ...]
13
14 Examples:
15 python3 scripts/sound_sync.py list --query whoosh
16 python3 scripts/sound_sync.py projects/deck bigsoundbank/1797 kenney-interface/click_001
17
18 Dependencies:
19 None (standard library only).
20
21 See templates/sounds/README.md.
22 """
23
24 from __future__ import annotations
25
26 import argparse
27 import hashlib
28 import json
29 import shutil
30 import sys
31 from pathlib import Path
32 from typing import Optional
33
34 from console_encoding import configure_utf8_stdio
35
36 configure_utf8_stdio()
37
38 _GLOBAL_SOUNDS_DIR = Path(__file__).resolve().parent.parent / "templates" / "sounds"
39 _INDEX_NAME = "sounds_index.json"
40
41
42 def _sha256(path: Path) -> str:
43 digest = hashlib.sha256()
44 with path.open("rb") as handle:
45 for chunk in iter(lambda: handle.read(1024 * 1024), b""):
46 digest.update(chunk)
47 return digest.hexdigest()
48
49
50 def _is_within(path: Path, root: Path) -> bool:
51 try:
52 path.relative_to(root)
53 except ValueError:
54 return False
55 return True
56
57
58 def _load_sounds(global_dir: Path) -> list[dict[str, object]]:
59 index_path = global_dir / _INDEX_NAME
60 try:
61 payload = json.loads(index_path.read_text(encoding="utf-8"))
62 except FileNotFoundError as exc:
63 raise RuntimeError(f"sound index not found: {index_path}") from exc
64 except (OSError, json.JSONDecodeError) as exc:
65 raise RuntimeError(f"cannot read sound index {index_path}: {exc}") from exc
66
67 if not isinstance(payload, dict):
68 raise RuntimeError(f"sound index root is not an object: {index_path}")
69 if payload.get("version") != 1:
70 raise RuntimeError(f"unsupported sound index version in {index_path}")
71
72 sounds = payload.get("sounds")
73 if not isinstance(sounds, list):
74 raise RuntimeError(f"sound index has no sounds list: {index_path}")
75 if payload.get("asset_count") != len(sounds):
76 raise RuntimeError(f"sound index count does not match its sounds list: {index_path}")
77
78 seen_ids: set[str] = set()
79 for item in sounds:
80 if not isinstance(item, dict):
81 raise RuntimeError(f"sound index entry is not an object: {index_path}")
82 sound_id = item.get("id")
83 relative_file = item.get("file")
84 sha256 = item.get("sha256")
85 if not all(isinstance(value, str) and value for value in (sound_id, relative_file, sha256)):
86 raise RuntimeError(f"sound index entry lacks id/file/sha256: {index_path}")
87 if sound_id in seen_ids:
88 raise RuntimeError(f"duplicate sound id in index: {sound_id}")
89 seen_ids.add(sound_id)
90
91 relative_path = Path(relative_file)
92 if relative_path.is_absolute() or ".." in relative_path.parts:
93 raise RuntimeError(f"unsafe sound path in index: {relative_file}")
94 if relative_path.suffix.lower() != ".wav":
95 raise RuntimeError(f"sound index entry is not WAV: {relative_file}")
96 if relative_path.with_suffix("").as_posix() != sound_id:
97 raise RuntimeError(f"sound id/file mismatch in index: {sound_id} != {relative_file}")
98
99 return sounds
100
101
102 def list_sounds(query: str = "", global_dir: Path = _GLOBAL_SOUNDS_DIR) -> list[dict[str, object]]:
103 """Return sounds whose metadata contains every case-insensitive query term."""
104 sounds = _load_sounds(global_dir)
105 terms = [term for term in query.casefold().split() if term]
106 if not terms:
107 return sounds
108
109 matches: list[dict[str, object]] = []
110 for item in sounds:
111 searchable = [
112 str(item.get("id", "")),
113 str(item.get("label", "")),
114 str(item.get("source", "")),
115 *(str(tag) for tag in item.get("tags", [])),
116 *(str(context) for context in item.get("contexts", [])),
117 ]
118 if item.get("recommended") is True:
119 searchable.append("recommended")
120 haystack = " ".join(searchable).casefold()
121 if all(term in haystack for term in terms):
122 matches.append(item)
123 return matches
124
125
126 def sync_sounds(
127 project_path: Path,
128 sound_ids: list[str],
129 global_dir: Path = _GLOBAL_SOUNDS_DIR,
130 ) -> tuple[list[str], list[str]]:
131 """Copy selected sound IDs into `<project>/sounds/` after validating the full batch.
132
133 Returns `(copied_or_present, missing)`. Any missing ID is reported before
134 creating the project-local directory or copying a file.
135 """
136 try:
137 project_root = project_path.resolve(strict=True)
138 except OSError as exc:
139 raise RuntimeError(f"cannot resolve project directory {project_path}: {exc}") from exc
140 if not project_root.is_dir():
141 raise RuntimeError(f"project is not a directory: {project_path}")
142
143 sounds = _load_sounds(global_dir)
144 catalog = {str(item["id"]): item for item in sounds}
145 requested = list(dict.fromkeys(sound_ids))
146 missing = [sound_id for sound_id in requested if sound_id not in catalog]
147 if missing:
148 return [], missing
149
150 validated: list[tuple[str, Path, Path, bool]] = []
151 for sound_id in requested:
152 item = catalog[sound_id]
153 relative_path = Path(str(item["file"]))
154 source = global_dir / relative_path
155 expected_sha256 = str(item["sha256"])
156 if not source.is_file():
157 raise RuntimeError(f"library file not found for {sound_id}: {source}")
158 actual_sha256 = _sha256(source)
159 if actual_sha256 != expected_sha256:
160 raise RuntimeError(
161 f"library checksum mismatch for {sound_id}: "
162 f"expected {expected_sha256}, got {actual_sha256}"
163 )
164 destination = project_root / "sounds" / relative_path
165 resolved_parent = destination.parent.resolve(strict=False)
166 resolved_destination = destination.resolve(strict=False)
167 if not _is_within(resolved_parent, project_root):
168 raise RuntimeError(
169 f"destination parent escapes project root for {sound_id}: {resolved_parent}"
170 )
171 if not _is_within(resolved_destination, project_root):
172 raise RuntimeError(
173 f"destination escapes project root for {sound_id}: {resolved_destination}"
174 )
175 if destination.is_symlink():
176 raise RuntimeError(f"destination symlink is not allowed for {sound_id}: {destination}")
177
178 already_present = False
179 if destination.exists():
180 if not destination.is_file():
181 raise RuntimeError(f"destination is not a regular file for {sound_id}: {destination}")
182 destination_sha256 = _sha256(destination)
183 if destination_sha256 != expected_sha256:
184 raise RuntimeError(
185 f"project sound conflicts with library id {sound_id}: {destination}; "
186 "remove or rename the existing file, then rerun"
187 )
188 already_present = True
189 validated.append((sound_id, source, destination, already_present))
190
191 copied: list[str] = []
192 for sound_id, source, destination, already_present in validated:
193 if already_present:
194 copied.append(f"{sound_id} (already in project)")
195 continue
196 destination.parent.mkdir(parents=True, exist_ok=True)
197 shutil.copy2(source, destination)
198 copied.append(sound_id)
199
200 return copied, []
201
202
203 def _build_list_parser() -> argparse.ArgumentParser:
204 parser = argparse.ArgumentParser(
205 prog="sound_sync.py list",
206 description="List bundled sounds, optionally filtered by metadata.",
207 formatter_class=argparse.RawDescriptionHelpFormatter,
208 )
209 parser.add_argument(
210 "--query",
211 default="",
212 help="Case-insensitive terms matched against ID, label, source, tags, and contexts",
213 )
214 return parser
215
216
217 def _build_sync_parser() -> argparse.ArgumentParser:
218 parser = argparse.ArgumentParser(
219 description=(
220 "Copy explicitly selected library sounds into a project's sounds/ folder. "
221 "Review `templates/sounds/sound-vocabulary.md` first; use "
222 "`sound_sync.py list [--query TERM]` only for optional exact filtering."
223 ),
224 formatter_class=argparse.RawDescriptionHelpFormatter,
225 )
226 parser.add_argument("project_path", help="Existing project directory")
227 parser.add_argument("sound_ids", nargs="+", help="Sound IDs such as bigsoundbank/1797")
228 return parser
229
230
231 def _run_list(argv: list[str]) -> int:
232 args = _build_list_parser().parse_args(argv)
233 try:
234 sounds = list_sounds(args.query)
235 except RuntimeError as exc:
236 print(f"[ERROR] {exc}", file=sys.stderr)
237 return 1
238
239 for item in sounds:
240 marker = "*" if item.get("recommended") is True else " "
241 sound_id = str(item["id"])
242 duration = float(item.get("duration_seconds", 0.0))
243 label = str(item.get("label", ""))
244 contexts = ",".join(str(value) for value in item.get("contexts", []))
245 print(f"{marker} {sound_id:<42} {duration:>7.3f}s {label} [{contexts}]")
246
247 print(f"[OK] {len(sounds)} sound(s) matched", file=sys.stderr)
248 return 0
249
250
251 def _run_sync(argv: list[str]) -> int:
252 args = _build_sync_parser().parse_args(argv)
253 project = Path(args.project_path)
254 if not project.is_dir():
255 print(f"[ERROR] project not found: {project}", file=sys.stderr)
256 return 1
257
258 try:
259 copied, missing = sync_sounds(project, args.sound_ids)
260 except RuntimeError as exc:
261 print(f"[ERROR] {exc}", file=sys.stderr)
262 return 1
263
264 if missing:
265 print(
266 f"[MISSING] {len(missing)} sound ID(s) not in the library; nothing was copied:",
267 file=sys.stderr,
268 )
269 for sound_id in missing:
270 print(f" x {sound_id}", file=sys.stderr)
271 print(
272 "Review `skills/ppt-master/templates/sounds/sound-vocabulary.md`, then "
273 "optionally run `python3 skills/ppt-master/scripts/sound_sync.py list "
274 "--query <term>` to locate an exact ID.",
275 file=sys.stderr,
276 )
277 return 1
278
279 print(f"[OK] {len(copied)} sound(s) in {project / 'sounds'}:", file=sys.stderr)
280 for sound_id in copied:
281 print(f" + {sound_id}", file=sys.stderr)
282 return 0
283
284
285 def main(argv: Optional[list[str]] = None) -> int:
286 raw_args = list(sys.argv[1:] if argv is None else argv)
287 if raw_args and raw_args[0] == "list":
288 return _run_list(raw_args[1:])
289 return _run_sync(raw_args)
290
291
292 if __name__ == "__main__":
293 raise SystemExit(main())
294
294 lines PYTHON