Upload narrator.py with huggingface_hub
Browse files- narrator.py +190 -93
narrator.py
CHANGED
|
@@ -2,39 +2,66 @@
|
|
| 2 |
"""
|
| 3 |
narrator.py
|
| 4 |
-----------
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 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 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
"
|
| 24 |
-
"equalizer=f=
|
| 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.
|
| 32 |
-
"compand=attacks=0.
|
| 33 |
-
"loudnorm=I=-16:TP=-1.5:LRA=
|
| 34 |
)
|
| 35 |
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
if
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
self,
|
| 83 |
text: str,
|
| 84 |
output_path: Union[str, Path],
|
| 85 |
-
speed: float = 0.
|
| 86 |
-
sentence_pause: float = 0.
|
| 87 |
-
clause_pause: float = 0.
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
speed=speed,
|
| 101 |
-
lang="en-gb",
|
| 102 |
sentence_pause=sentence_pause,
|
| 103 |
clause_pause=clause_pause,
|
| 104 |
-
|
| 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 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|