Spaces:
Runtime error
Runtime error
File size: 11,549 Bytes
71fd59e | 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 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 | import os
import re
import sys
import uuid
import zipfile
import shutil
import random
import nltk
import gradio as gr
from g2p_en import G2p
from pydub import AudioSegment
# Pre-download required NLTK data
nltk.download('averaged_perceptron_tagger')
nltk.download('averaged_perceptron_tagger_eng', quiet=True)
BASE_CHARACTERS_DIR = os.path.join("assets", "characters")
os.makedirs(BASE_CHARACTERS_DIR, exist_ok=True)
class TextToSpeech:
PHONEME_MAPPING = {
'AW': ['AE', 'OW'], 'DH': ['D'], 'EY': ['EH', 'IY'], 'JH': ['CH'],
'SH': ['CH'], 'TH': ['D'], 'ZH': ['CH'], 'AE': ['AA'],
'AO': ['AA', 'OW'], 'ER': ['AA'], 'IH': ['IY'],
'OY': ['OW', 'Y', 'IY'], 'UH': ['UW'], 'AH': ['AA']
}
def __init__(self, character_folder):
self.character_folder = character_folder
self.g2p = G2p()
self.word_pause_ms = 1 # 0.001 seconds -> 1 ms
self.fade_duration_ms = 10 # 0.010 seconds -> 10 ms
# Target specs
self.target_channels = 1
self.target_rate = 44100
def _pick_random_variant(self, base_path):
directory = os.path.dirname(base_path)
base_name = os.path.splitext(os.path.basename(base_path))[0]
if not os.path.isdir(directory):
return None
pattern = re.compile(rf"^{re.escape(base_name)}(_\d+)?\.wav$", re.IGNORECASE)
candidates = [os.path.join(directory, f) for f in os.listdir(directory) if pattern.match(f)]
return random.choice(candidates) if candidates else None
def _normalize_audio(self, filepath):
try:
audio = AudioSegment.from_wav(filepath)
if audio.channels > 1:
audio = audio.set_channels(self.target_channels)
if audio.frame_rate != self.target_rate:
audio = audio.set_frame_rate(self.target_rate)
return audio
except Exception:
return None
def _get_phoneme_data(self, phoneme):
if phoneme == "AH0":
chosen_fallback = random.choice(["AA", "AH"])
return self._get_phoneme_data(chosen_fallback)
base = os.path.join(self.character_folder, f"{phoneme}.wav")
path = self._pick_random_variant(base)
if path:
return self._normalize_audio(path)
if phoneme in self.PHONEME_MAPPING:
combined_audio = None
for sub_p in self.PHONEME_MAPPING[phoneme]:
sub_audio = self._get_phoneme_data(sub_p)
if sub_audio:
if combined_audio:
combined_audio = combined_audio.append(sub_audio, crossfade=min(self.fade_duration_ms, len(combined_audio), len(sub_audio)))
else:
combined_audio = sub_audio
return combined_audio
return None
def generate_audio_data(self, str_input):
tokens = re.findall(r"[\w']+|[.,!?;]", str_input)
raw_segments = []
for token in tokens:
if token in [".", "!", "?", ",", ";"]:
dur_ms = 400 if token in [".", "!", "?"] else 220
raw_segments.append({"audio": AudioSegment.silent(duration=dur_ms), "is_pause": True})
continue
word_wav = self._pick_random_variant(os.path.join(self.character_folder, "words", f"{token.upper()}.wav"))
if word_wav:
norm_word = self._normalize_audio(word_wav)
if norm_word:
raw_segments.append({"audio": norm_word, "is_pause": False})
else:
phonemes = self.g2p(token)
valid_ps = [re.sub(r'\d+', '', p) if p != "AH0" else p for p in phonemes]
valid_ps = [p for p in valid_ps if re.match(r'[A-Z]+[0-9]*', p)]
if valid_ps and valid_ps[-1] in ["AH", "AE", "AH0"]:
valid_ps[-1] = random.choice(["AA", "AH"])
for p_clean in valid_ps:
seg_audio = self._get_phoneme_data(p_clean)
if seg_audio:
raw_segments.append({"audio": seg_audio, "is_pause": False})
raw_segments.append({"audio": AudioSegment.silent(duration=self.word_pause_ms), "is_pause": True})
if not raw_segments:
return AudioSegment.silent(duration=100)
final_audio = None
for i in range(len(raw_segments)):
curr_audio = raw_segments[i]["audio"]
if final_audio is None:
final_audio = curr_audio
continue
# Apply crossfade if neither side is a pause segment
if not raw_segments[i-1]["is_pause"] and not raw_segments[i]["is_pause"]:
fade_size = min(self.fade_duration_ms, len(final_audio), len(curr_audio))
if fade_size > 0:
final_audio = final_audio.append(curr_audio, crossfade=fade_size)
else:
final_audio += curr_audio
else:
final_audio += curr_audio
return final_audio
def render_to_file(self, str_input, output_path):
audio_segment = self.generate_audio_data(str_input)
audio_segment.export(output_path, format="wav")
# --- Helper functions for Managing Categories & ZIP uploads ---
def get_hierarchy():
"""Scans the assets directory and returns structural mapping."""
categories = {}
if not os.path.isdir(BASE_CHARACTERS_DIR):
return categories
for cat in sorted(os.listdir(BASE_CHARACTERS_DIR)):
cat_p = os.path.join(BASE_CHARACTERS_DIR, cat)
if os.path.isdir(cat_p):
chars = [c for c in os.listdir(cat_p) if os.path.isdir(os.path.join(cat_p, c))]
if chars:
categories[cat] = sorted(chars)
return categories
def handle_zip_upload(file_obj):
"""Unpacks zipped voice lines into the expected directory schema."""
if file_obj is None:
return gr.update(), gr.update(), "No file uploaded."
try:
temp_extract = os.path.join("assets", f"temp_{uuid.uuid4().hex[:6]}")
with zipfile.ZipFile(file_obj.name, 'r') as zip_ref:
zip_ref.extractall(temp_extract)
# Figure out internal structure and migrate valid directories
for root, dirs, files in os.walk(temp_extract):
# If directory contains wav files directly, treat it as a character folder
if any(f.lower().endswith('.wav') for f in files):
char_name = os.path.basename(root)
parent_name = os.path.basename(os.path.dirname(root))
# If parent folder is just the root temp extraction layout, assign a generic Category
category_name = parent_name if parent_name != os.path.basename(temp_extract) else "Uploaded"
dest_dir = os.path.join(BASE_CHARACTERS_DIR, category_name, char_name)
os.makedirs(os.path.dirname(dest_dir), exist_ok=True)
if os.path.exists(dest_dir):
shutil.rmtree(dest_dir)
shutil.copytree(root, dest_dir)
shutil.rmtree(temp_extract)
# Refresh configuration selections
hierarchy = get_hierarchy()
cats = list(hierarchy.keys())
default_cat = cats[0] if cats else None
default_chars = hierarchy[default_cat] if default_cat else []
return (
gr.update(choices=cats, value=default_cat),
gr.update(choices=default_chars, value=default_chars[0] if default_chars else None),
"Voice pack uploaded and cataloged successfully!"
)
except Exception as e:
return gr.update(), gr.update(), f"Error processing file: {str(e)}"
def update_characters(category):
hierarchy = get_hierarchy()
chars = hierarchy.get(category, [])
return gr.update(choices=chars, value=chars[0] if chars else None)
def update_profile_preview(category, character):
if not category or not character:
return None
profile_path = os.path.join(BASE_CHARACTERS_DIR, category, character, "profile.png")
if os.path.exists(profile_path):
return profile_path
return None
def synthesize(category, character, text):
if not category or not character:
raise gr.Error("Please ensure a valid Category and Character are active.")
if not text.strip():
raise gr.Error("Text field cannot be left blank.")
char_path = os.path.join(BASE_CHARACTERS_DIR, category, character)
tts = TextToSpeech(char_path)
out_filename = f"output_{uuid.uuid4().hex[:8]}.wav"
tts.render_to_file(text, out_filename)
return out_filename
# --- Gradio UI Block Setup ---
initial_hierarchy = get_hierarchy()
initial_cats = list(initial_hierarchy.keys())
initial_chars = initial_hierarchy[initial_cats[0]] if initial_cats else []
with gr.Blocks(theme=gr.themes.Soft(primary_hue="amber", neutral_hue="slate")) as demo:
gr.Markdown("# 🎙️ Sentence Mixing TTS Generator")
gr.Markdown("An elegant web interface for sentence-mixing speech generation. Upload voice line assets or choose a character configuration to begin.")
with gr.Row():
with gr.Column(scale=1):
profile_preview = gr.Image(
value=update_profile_preview(initial_cats[0], initial_chars[0]) if initial_chars else None,
label="Character Profile",
height=220,
width=220,
interactive=False,
circle=True
)
category_drop = gr.Dropdown(choices=initial_cats, value=initial_cats[0] if initial_cats else None, label="Voice Category")
character_drop = gr.Dropdown(choices=initial_chars, value=initial_chars[0] if initial_chars else None, label="Character")
category_drop.change(update_characters, inputs=category_drop, outputs=character_drop)
character_drop.change(update_profile_preview, inputs=[category_drop, character_drop], outputs=profile_preview)
with gr.Column(scale=2):
input_text = gr.Textbox(label="Text to Synthesize", lines=6, placeholder="Type your text sentence here...")
submit_btn = gr.Button("📢 Speak / Generate", variant="primary")
audio_output = gr.Audio(label="Synthesized Audio Output", type="filepath")
submit_btn.click(synthesize, inputs=[category_drop, character_drop, input_text], outputs=audio_output)
with gr.Accordion("⚙️ Upload New Voice Assets (.zip)", open=False):
gr.Markdown("""
### Expected `.zip` Internal Structure
You can pack folders into your zip file. For example:
* `MyCharacter/AA.wav`, `MyCharacter/B.wav`, etc.
* `MyCharacter/words/HELLO.wav` (Optional)
* `MyCharacter/profile.png` (Optional round-cropped display icon)
""")
zip_uploader = gr.File(label="Choose Voice Zip File", file_types=[".zip"])
upload_status = gr.Markdown(value="Waiting for file upload...")
upload_btn = gr.Button("📦 Unpack & Register Voice Pack")
upload_btn.click(
handle_zip_upload,
inputs=zip_uploader,
outputs=[category_drop, character_drop, upload_status]
)
if __name__ == "__main__":
demo.launch()
|