gitmodelmujtaba commited on
Commit
f6a2230
·
verified ·
1 Parent(s): 4e356d0

Upload narrator.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. narrator.py +190 -93
narrator.py CHANGED
@@ -2,39 +2,66 @@
2
  """
3
  narrator.py
4
  -----------
5
- Turnkey documentary voiceover generator for Kokoro Grounded Documentary.
6
- Can be imported directly into any Python project or run via CLI.
 
7
  """
8
 
9
  import os
 
 
 
10
  import subprocess
11
  from pathlib import Path
12
- from typing import Optional, Union
13
  import numpy as np
14
  import soundfile as sf
15
  from kokoro_onnx import Kokoro
16
 
17
- try:
18
- from huggingface_hub import hf_hub_download
19
- except ImportError:
20
- hf_hub_download = None
21
-
22
- DEFAULT_BROADCAST_FILTER = (
23
- "highpass=f=80,"
24
- "equalizer=f=160:t=q:w=1.0:g=2.8,"
25
- "equalizer=f=260:t=q:w=1.2:g=2.0,"
26
- "equalizer=f=450:t=q:w=2.0:g=-2.0,"
27
- "equalizer=f=3200:t=q:w=2.0:g=-1.6,"
28
- "equalizer=f=5800:t=q:w=2.5:g=-3.5,"
29
  "equalizer=f=7200:t=q:w=2.5:g=-3.0,"
30
  "lowpass=f=11000,"
31
- "deesser=i=0.45:m=0.5:f=0.55:s=o,"
32
- "compand=attacks=0.03:decays=0.3:points=-80/-80|-35/-25|-16/-13|0/-3,"
33
- "loudnorm=I=-16:TP=-1.5:LRA=8.5"
34
  )
35
 
36
 
37
- class GroundedNarrator:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  """
39
  Broadcast-grade documentary narrator using Kokoro-ONNX and the Grounded Documentary profile.
40
  """
@@ -44,89 +71,159 @@ class GroundedNarrator:
44
  model_path: Optional[Union[str, Path]] = None,
45
  voices_path: Optional[Union[str, Path]] = None,
46
  profile_path: Optional[Union[str, Path]] = None,
47
- repo_id: Optional[str] = None
48
  ):
49
- base_dir = Path(__file__).resolve().parent
50
-
51
- if model_path is None:
52
- local_model = base_dir / "kokoro-v1.0.onnx"
53
- if local_model.exists():
54
- model_path = local_model
55
- elif repo_id and hf_hub_download:
56
- model_path = hf_hub_download(repo_id=repo_id, filename="kokoro-v1.0.onnx")
57
- else:
58
- raise FileNotFoundError("kokoro-v1.0.onnx not found. Provide model_path or repo_id.")
59
-
60
- if voices_path is None:
61
- local_voices = base_dir / "voices-v1.0.bin"
62
- if local_voices.exists():
63
- voices_path = local_voices
64
- elif repo_id and hf_hub_download:
65
- voices_path = hf_hub_download(repo_id=repo_id, filename="voices-v1.0.bin")
66
- else:
67
- raise FileNotFoundError("voices-v1.0.bin not found. Provide voices_path or repo_id.")
68
-
69
- if profile_path is None:
70
- local_prof = base_dir / "grounded_documentary.npy"
71
- if local_prof.exists():
72
- profile_path = local_prof
73
- elif repo_id and hf_hub_download:
74
- profile_path = hf_hub_download(repo_id=repo_id, filename="grounded_documentary.npy")
75
- else:
76
- raise FileNotFoundError("grounded_documentary.npy not found.")
77
-
78
- self.kokoro = Kokoro(str(model_path), str(voices_path))
79
- self.voice_style = np.load(str(profile_path))
80
-
81
- def synthesize(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  self,
83
  text: str,
84
  output_path: Union[str, Path],
85
- speed: float = 0.92,
86
- sentence_pause: float = 0.42,
87
- clause_pause: float = 0.20,
88
  apply_mastering: bool = True
89
  ) -> float:
90
- """
91
- Synthesizes text to WAV/MP3 with deliberate pacing and broadcast mastering.
92
- """
93
  out_p = Path(output_path)
94
- out_p.parent.mkdir(parents=True, exist_ok=True)
95
  wav_path = out_p.with_suffix(".wav")
96
-
97
- audio, sr = self.kokoro.create(
98
- text=text,
99
- voice=self.voice_style,
 
 
 
 
100
  speed=speed,
101
- lang="en-gb",
102
  sentence_pause=sentence_pause,
103
  clause_pause=clause_pause,
104
- continuous=False
105
  )
106
-
107
- sf.write(str(wav_path), audio, sr)
108
- dur = len(audio) / sr
109
-
110
- if out_p.suffix.lower() == ".mp3" or apply_mastering:
111
- mp3_path = out_p.with_suffix(".mp3")
112
- cmd = ["ffmpeg", "-y", "-i", str(wav_path)]
113
- if apply_mastering:
114
- cmd.extend(["-af", DEFAULT_BROADCAST_FILTER])
115
- cmd.extend(["-c:a", "libmp3lame", "-b:a", "192k", str(mp3_path)])
116
- subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
117
- if out_p.suffix.lower() == ".mp3" and wav_path != out_p:
118
- wav_path.unlink(missing_ok=True)
119
-
120
- return dur
121
-
122
-
123
- if __name__ == "__main__":
124
- import argparse
125
- parser = argparse.ArgumentParser(description="Generate broadcast documentary narration")
126
- parser.add_argument("text", type=str, help="Text to speak")
127
- parser.add_argument("-o", "--output", type=str, default="narration.mp3", help="Output path (.mp3 or .wav)")
128
- args = parser.parse_args()
129
-
130
- narrator = GroundedNarrator()
131
- dur = narrator.synthesize(args.text, args.output)
132
- print(f"Generated {dur:.2f}s of mastered voiceover -> {args.output}")
 
2
  """
3
  narrator.py
4
  -----------
5
+ Core VoiceoverNarrator class for the kokoro-voiceover package.
6
+ Provides broadcast-grade documentary and long-form speech synthesis using Kokoro-ONNX
7
+ and the tuned Grounded Documentary voice profile (55% bm_george + 45% bm_daniel).
8
  """
9
 
10
  import os
11
+ import re
12
+ import json
13
+ import time
14
  import subprocess
15
  from pathlib import Path
16
+ from typing import Optional, Union, List, Tuple, Dict, Any
17
  import numpy as np
18
  import soundfile as sf
19
  from kokoro_onnx import Kokoro
20
 
21
+ from .pronunciation import apply_pronunciation_lexicon
22
+
23
+ # Gemini-Calibrated Broadcast Mastering Filter Chain (Warm sub-chest resonance + surgical de-essing)
24
+ BROADCAST_AUDIO_FILTER = (
25
+ "highpass=f=75:p=2,"
26
+ "equalizer=f=180:t=q:w=1.2:g=3.2,"
27
+ "equalizer=f=480:t=q:w=2.0:g=-1.8,"
28
+ "equalizer=f=3200:t=q:w=1.5:g=1.2,"
 
 
 
 
29
  "equalizer=f=7200:t=q:w=2.5:g=-3.0,"
30
  "lowpass=f=11000,"
31
+ "deesser=i=0.7:m=0.5:f=0.65:s=o,"
32
+ "compand=attacks=0.02:decays=0.25:points=-70/-70|-45/-32|-20/-14|0/-6:soft-knee=6,"
33
+ "loudnorm=I=-16:TP=-1.5:LRA=9"
34
  )
35
 
36
 
37
+ def clean_text_base(raw_text: str, apply_lexicon: bool = True) -> str:
38
+ """Sanitizes raw markdown script text for natural TTS delivery and applies phonetic lexicon."""
39
+ t = raw_text
40
+ if apply_lexicon:
41
+ t = apply_pronunciation_lexicon(t)
42
+ t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t)
43
+ t = re.sub(r"\*([^*]+)\*", r"\1", t)
44
+ t = re.sub(r"`([^`]+)`", r"\1", t)
45
+ t = re.sub(r"#{1,6}\s*", "", t)
46
+ t = re.sub(r"\[(?:PAUSE|BREATH|BEAT|SILENCE|MUSIC|SFX|STAGE DIRECTION)[^\]]*\]", "", t, flags=re.IGNORECASE)
47
+ t = re.sub(r"\[[^\]]*\]", "", t)
48
+ t = re.sub(r"\((?:pause|breath|beat|silence)[^)]*\)", "", t, flags=re.IGNORECASE)
49
+ t = t.replace("\ufffd", " - ")
50
+ t = t.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"')
51
+ t = t.replace("—", " - ").replace("–", " - ")
52
+ t = re.sub(r"\s*\(([^)]+)\)\s*", r" — \1 — ", t)
53
+ t = t.replace(";", " — ")
54
+ t = re.sub(r":\s+", " — ", t)
55
+ t = re.sub(r"\.{3,}", "...", t)
56
+ t = re.sub(r"\.\.\.(?=[A-Za-z])", "... ", t)
57
+ t = re.sub(r"\s*—\s*", " — ", t)
58
+ t = re.sub(r"\s*-\s*", " - ", t)
59
+ t = re.sub(r"( —\s*)+", " — ", t)
60
+ t = re.sub(r"[ \t]+", " ", t)
61
+ return t.strip()
62
+
63
+
64
+ class VoiceoverNarrator:
65
  """
66
  Broadcast-grade documentary narrator using Kokoro-ONNX and the Grounded Documentary profile.
67
  """
 
71
  model_path: Optional[Union[str, Path]] = None,
72
  voices_path: Optional[Union[str, Path]] = None,
73
  profile_path: Optional[Union[str, Path]] = None,
 
74
  ):
75
+ root_dir = Path(__file__).resolve().parents[4] # repo root
76
+ default_model = root_dir / "models" / "kokoro" / "kokoro-v1.0.onnx"
77
+ default_voices = root_dir / "models" / "kokoro" / "voices-v1.0.bin"
78
+ default_profile = root_dir / "models" / "kokoro" / "grounded_documentary.npy"
79
+
80
+ self.model_path = Path(model_path) if model_path else default_model
81
+ self.voices_path = Path(voices_path) if voices_path else default_voices
82
+ self.profile_path = Path(profile_path) if profile_path else default_profile
83
+
84
+ if not self.model_path.exists():
85
+ raise FileNotFoundError(f"Kokoro model not found at: {self.model_path}")
86
+ if not self.voices_path.exists():
87
+ raise FileNotFoundError(f"Voices library not found at: {self.voices_path}")
88
+
89
+ self.kokoro = Kokoro(str(self.model_path), str(self.voices_path))
90
+
91
+ if self.profile_path.exists():
92
+ self.voice_style = np.load(str(self.profile_path))
93
+ self.voice_label = "grounded_documentary (loaded profile)"
94
+ else:
95
+ v_daniel = self.kokoro.get_voice_style("bm_daniel")
96
+ v_george = self.kokoro.get_voice_style("bm_george")
97
+ self.voice_style = 0.55 * v_george + 0.45 * v_daniel
98
+ self.voice_label = "grounded_documentary (55% bm_george + 45% bm_daniel)"
99
+
100
+ def synthesize_chunks(
101
+ self,
102
+ chunks: List[Tuple[str, float]],
103
+ out_wav: Path,
104
+ out_mp3: Path,
105
+ out_json: Optional[Path] = None,
106
+ section_name: str = "Voiceover",
107
+ speed: float = 0.91,
108
+ sentence_pause: float = 0.65,
109
+ clause_pause: float = 0.30,
110
+ lang: str = "en-gb",
111
+ apply_mastering: bool = True,
112
+ ) -> float:
113
+ """
114
+ Synthesizes structured chunks into a broadcast-mastered MP3 audio track.
115
+ Each chunk is (text, pause_after_seconds).
116
+ """
117
+ out_wav.parent.mkdir(parents=True, exist_ok=True)
118
+ out_mp3.parent.mkdir(parents=True, exist_ok=True)
119
+
120
+ audio_segments = []
121
+ sample_rate = 24000
122
+ meta_segments = []
123
+ current_time = 0.0
124
+ t_start = time.time()
125
+ total_words = sum(len(text.split()) for text, _ in chunks)
126
+
127
+ print(f"\n--- Synthesizing: {section_name} ({len(chunks)} chunks, {total_words} words) ---")
128
+
129
+ for idx, (chunk_text, pause_after) in enumerate(chunks, 1):
130
+ cleaned = clean_text_base(chunk_text)
131
+ if not cleaned:
132
+ continue
133
+ words = len(cleaned.split())
134
+ print(f" [{idx}/{len(chunks)}] ({words}w, pause {pause_after:.2f}s): {cleaned[:60]}...")
135
+
136
+ seg_audio, sr = self.kokoro.create(
137
+ cleaned,
138
+ voice=self.voice_style,
139
+ speed=speed,
140
+ lang=lang,
141
+ sentence_pause=sentence_pause,
142
+ clause_pause=clause_pause,
143
+ continuous=False
144
+ )
145
+ sample_rate = sr
146
+ seg_dur = len(seg_audio) / sr
147
+
148
+ meta_segments.append({
149
+ "unit_index": idx,
150
+ "text": cleaned,
151
+ "word_count": words,
152
+ "start_time": round(current_time, 3),
153
+ "end_time": round(current_time + seg_dur, 3),
154
+ "duration": round(seg_dur, 3),
155
+ "pause_after": round(pause_after, 3)
156
+ })
157
+ audio_segments.append(seg_audio)
158
+ current_time += seg_dur
159
+
160
+ if pause_after > 0:
161
+ silence_samples = int(sample_rate * pause_after)
162
+ audio_segments.append(np.zeros(silence_samples, dtype=np.float32))
163
+ current_time += pause_after
164
+
165
+ full_audio = np.concatenate(audio_segments)
166
+ total_dur = len(full_audio) / sample_rate
167
+ elapsed = time.time() - t_start
168
+
169
+ # 1. Save WAV
170
+ sf.write(str(out_wav), full_audio, sample_rate)
171
+
172
+ # 2. Master to MP3
173
+ cmd_mp3 = ["ffmpeg", "-y", "-i", str(out_wav)]
174
+ if apply_mastering:
175
+ cmd_mp3.extend(["-af", BROADCAST_AUDIO_FILTER])
176
+ cmd_mp3.extend([
177
+ "-c:a", "libmp3lame",
178
+ "-b:a", "192k",
179
+ str(out_mp3)
180
+ ])
181
+ subprocess.run(cmd_mp3, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
182
+
183
+ # 3. Save JSON metadata
184
+ if out_json:
185
+ meta = {
186
+ "section": section_name,
187
+ "voice_profile": self.voice_label,
188
+ "lang": lang,
189
+ "speed": speed,
190
+ "sentence_pause": sentence_pause,
191
+ "clause_pause": clause_pause,
192
+ "sample_rate": sample_rate,
193
+ "duration_seconds": round(total_dur, 3),
194
+ "word_count": total_words,
195
+ "generation_time_seconds": round(elapsed, 2),
196
+ "mastered": apply_mastering,
197
+ "units": meta_segments
198
+ }
199
+ with open(out_json, "w", encoding="utf-8") as jf:
200
+ json.dump(meta, jf, indent=2)
201
+
202
+ print(f"-> Finished {section_name}: {total_dur:.2f}s (~{total_dur/60:.2f} min) in {elapsed:.1f}s")
203
+ return total_dur
204
+
205
+ def synthesize_text(
206
  self,
207
  text: str,
208
  output_path: Union[str, Path],
209
+ speed: float = 0.91,
210
+ sentence_pause: float = 0.65,
211
+ clause_pause: float = 0.30,
212
  apply_mastering: bool = True
213
  ) -> float:
214
+ """Single-string simple synthesis helper."""
 
 
215
  out_p = Path(output_path)
 
216
  wav_path = out_p.with_suffix(".wav")
217
+ mp3_path = out_p.with_suffix(".mp3")
218
+ chunks = [(p.strip(), 0.85) for p in text.split("\n\n") if p.strip()]
219
+ return self.synthesize_chunks(
220
+ chunks=chunks,
221
+ out_wav=wav_path,
222
+ out_mp3=mp3_path,
223
+ out_json=out_p.with_suffix(".json"),
224
+ section_name=out_p.stem,
225
  speed=speed,
 
226
  sentence_pause=sentence_pause,
227
  clause_pause=clause_pause,
228
+ apply_mastering=apply_mastering
229
  )