-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgenerate_base_voice_callouts.py
More file actions
159 lines (127 loc) · 5.46 KB
/
Copy pathgenerate_base_voice_callouts.py
File metadata and controls
159 lines (127 loc) · 5.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#!/usr/bin/env python3
"""Generate missing base voice-callout mp3s via ElevenLabs.
Reads the Android commandCues JSON (authoritative), finds entries whose
male mp3 is missing in native-android/app/src/main/res/raw/, renders them
via ElevenLabs, and writes both male and (optionally) female mp3s into the
bundled audio directories for iOS + Android.
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
_SAFE_FILENAME = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
def _safe_filename(filename: str) -> str:
# Strip any path components Sonar taint analysis cares about, then
# enforce an alphanumeric/underscore whitelist. Both together.
stripped = os.path.basename(filename)
if not _SAFE_FILENAME.fullmatch(stripped):
raise ValueError(f"Unsafe cue filename rejected: {filename!r}")
return stripped
sys.path.insert(0, str(Path(__file__).resolve().parent))
from generate_pro_audio_content import ( # noqa: E402
ANDROID_VOICE_CATALOG_PATH,
REPO_ROOT,
_generate_voice_assets,
_load_voice_settings,
)
ANDROID_RAW_DIR = REPO_ROOT / "native-android/app/src/main/res/raw"
IOS_AUDIO_DIR = REPO_ROOT / "native-ios/RandomTimer/Resources/Audio"
IOS_FEMALE_AUDIO_DIR = IOS_AUDIO_DIR / "female"
DEFAULT_VOICE_SETTINGS = {
"stability": 0.6,
"similarity_boost": 0.75,
"style": 0.3,
"use_speaker_boost": True,
}
VOICE_PERSONAS_PATH = REPO_ROOT / "content/pro_audio/voice_personas.json"
FORBIDDEN_MALE_VOICE_ID = "DGzg6RaUqxGRTHSBjfgF" # ElevenLabs "Angst" (San Francisco accent)
def _default_male_voice_id() -> str:
contract = json.loads(VOICE_PERSONAS_PATH.read_text(encoding="utf-8"))
voice_id = contract["male"]["voiceId"].strip()
if voice_id == FORBIDDEN_MALE_VOICE_ID:
raise SystemExit(f"Refusing forbidden male voice ID (Angst): {voice_id}")
return voice_id
def _all_male_cue_lines(catalog: dict) -> list[tuple[str, str]]:
lines: list[tuple[str, str]] = [(catalog["previewElapsed"]["filename"], catalog["previewElapsed"]["text"])]
for cue in catalog["elapsedCues"]:
lines.append((cue["filename"], cue["text"]))
for cue in catalog["commandCues"]:
lines.append((cue["filename"], cue["text"]))
return lines
def _existing_android_raw_stems() -> set[str]:
return {path.stem for path in ANDROID_RAW_DIR.glob("*.mp3")}
def _missing_male_cues(catalog: dict) -> list[tuple[str, str]]:
existing = _existing_android_raw_stems()
result: list[tuple[str, str]] = []
for cue in catalog["commandCues"]:
name = _safe_filename(cue["filename"])
if name not in existing:
result.append((name, cue["text"]))
return result
def _write_mirror(src_dir: Path, dst_dir: Path, stem: str, dst_prefix: str = "") -> None:
src_dir_resolved = src_dir.resolve()
dst_dir_resolved = dst_dir.resolve()
src = src_dir_resolved / f"{stem}.mp3"
dst = dst_dir_resolved / f"{dst_prefix}{stem}.mp3"
if src.parent != src_dir_resolved or dst.parent != dst_dir_resolved:
raise ValueError(f"Mirror path escaped allowed dir: {src} -> {dst}")
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(src.read_bytes())
print(f"mirrored -> {dst}")
def main() -> int:
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--regenerate-all-male",
action="store_true",
help="Regenerate every male bundled cue (command, elapsed, preview), not only missing files.",
)
args = parser.parse_args()
api_key = os.environ.get("ELEVENLABS_API_KEY", "").strip()
if not api_key:
print("ELEVENLABS_API_KEY not set", file=sys.stderr)
return 2
male_voice_id = os.environ.get("MALE_VOICE_ID", _default_male_voice_id()).strip()
if male_voice_id == FORBIDDEN_MALE_VOICE_ID:
print(f"Refusing forbidden male voice ID (Angst): {male_voice_id}", file=sys.stderr)
return 2
female_voice_id = os.environ.get("FEMALE_VOICE_ID", "").strip()
model_id = os.environ.get("MODEL_ID", "eleven_multilingual_v2").strip()
catalog = json.loads(ANDROID_VOICE_CATALOG_PATH.read_text(encoding="utf-8"))
missing = _all_male_cue_lines(catalog) if args.regenerate_all_male else _missing_male_cues(catalog)
if not missing:
print("No missing cues. Nothing to generate.")
return 0
print(f"Generating {len(missing)} male cue(s):")
for stem, text in missing:
print(f" - {stem}: {text!r}")
male_settings = _load_voice_settings(api_key, male_voice_id, DEFAULT_VOICE_SETTINGS)
_generate_voice_assets(
api_key=api_key,
voice_id=male_voice_id,
model_id=model_id,
voice_settings=male_settings,
lines=missing,
output_dir=IOS_AUDIO_DIR,
)
for stem, _ in missing:
_write_mirror(IOS_AUDIO_DIR, ANDROID_RAW_DIR, stem)
if female_voice_id:
female_settings = _load_voice_settings(api_key, female_voice_id, DEFAULT_VOICE_SETTINGS)
_generate_voice_assets(
api_key=api_key,
voice_id=female_voice_id,
model_id=model_id,
voice_settings=female_settings,
lines=missing,
output_dir=IOS_FEMALE_AUDIO_DIR,
)
for stem, _ in missing:
_write_mirror(IOS_FEMALE_AUDIO_DIR, ANDROID_RAW_DIR, stem, dst_prefix="female_")
else:
print("FEMALE_VOICE_ID not set — skipping female render.")
return 0
if __name__ == "__main__":
raise SystemExit(main())