aeuhhh commited on
Commit
71fd59e
·
verified ·
1 Parent(s): 786f204

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +274 -0
  2. requirements.txt +4 -0
app.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import sys
4
+ import uuid
5
+ import zipfile
6
+ import shutil
7
+ import random
8
+ import nltk
9
+ import gradio as gr
10
+ from g2p_en import G2p
11
+ from pydub import AudioSegment
12
+
13
+ # Pre-download required NLTK data
14
+ nltk.download('averaged_perceptron_tagger')
15
+ nltk.download('averaged_perceptron_tagger_eng', quiet=True)
16
+
17
+ BASE_CHARACTERS_DIR = os.path.join("assets", "characters")
18
+ os.makedirs(BASE_CHARACTERS_DIR, exist_ok=True)
19
+
20
+ class TextToSpeech:
21
+ PHONEME_MAPPING = {
22
+ 'AW': ['AE', 'OW'], 'DH': ['D'], 'EY': ['EH', 'IY'], 'JH': ['CH'],
23
+ 'SH': ['CH'], 'TH': ['D'], 'ZH': ['CH'], 'AE': ['AA'],
24
+ 'AO': ['AA', 'OW'], 'ER': ['AA'], 'IH': ['IY'],
25
+ 'OY': ['OW', 'Y', 'IY'], 'UH': ['UW'], 'AH': ['AA']
26
+ }
27
+
28
+ def __init__(self, character_folder):
29
+ self.character_folder = character_folder
30
+ self.g2p = G2p()
31
+ self.word_pause_ms = 1 # 0.001 seconds -> 1 ms
32
+ self.fade_duration_ms = 10 # 0.010 seconds -> 10 ms
33
+
34
+ # Target specs
35
+ self.target_channels = 1
36
+ self.target_rate = 44100
37
+
38
+ def _pick_random_variant(self, base_path):
39
+ directory = os.path.dirname(base_path)
40
+ base_name = os.path.splitext(os.path.basename(base_path))[0]
41
+ if not os.path.isdir(directory):
42
+ return None
43
+ pattern = re.compile(rf"^{re.escape(base_name)}(_\d+)?\.wav$", re.IGNORECASE)
44
+ candidates = [os.path.join(directory, f) for f in os.listdir(directory) if pattern.match(f)]
45
+ return random.choice(candidates) if candidates else None
46
+
47
+ def _normalize_audio(self, filepath):
48
+ try:
49
+ audio = AudioSegment.from_wav(filepath)
50
+ if audio.channels > 1:
51
+ audio = audio.set_channels(self.target_channels)
52
+ if audio.frame_rate != self.target_rate:
53
+ audio = audio.set_frame_rate(self.target_rate)
54
+ return audio
55
+ except Exception:
56
+ return None
57
+
58
+ def _get_phoneme_data(self, phoneme):
59
+ if phoneme == "AH0":
60
+ chosen_fallback = random.choice(["AA", "AH"])
61
+ return self._get_phoneme_data(chosen_fallback)
62
+
63
+ base = os.path.join(self.character_folder, f"{phoneme}.wav")
64
+ path = self._pick_random_variant(base)
65
+ if path:
66
+ return self._normalize_audio(path)
67
+
68
+ if phoneme in self.PHONEME_MAPPING:
69
+ combined_audio = None
70
+ for sub_p in self.PHONEME_MAPPING[phoneme]:
71
+ sub_audio = self._get_phoneme_data(sub_p)
72
+ if sub_audio:
73
+ if combined_audio:
74
+ combined_audio = combined_audio.append(sub_audio, crossfade=min(self.fade_duration_ms, len(combined_audio), len(sub_audio)))
75
+ else:
76
+ combined_audio = sub_audio
77
+ return combined_audio
78
+ return None
79
+
80
+ def generate_audio_data(self, str_input):
81
+ tokens = re.findall(r"[\w']+|[.,!?;]", str_input)
82
+ raw_segments = []
83
+
84
+ for token in tokens:
85
+ if token in [".", "!", "?", ",", ";"]:
86
+ dur_ms = 400 if token in [".", "!", "?"] else 220
87
+ raw_segments.append({"audio": AudioSegment.silent(duration=dur_ms), "is_pause": True})
88
+ continue
89
+
90
+ word_wav = self._pick_random_variant(os.path.join(self.character_folder, "words", f"{token.upper()}.wav"))
91
+ if word_wav:
92
+ norm_word = self._normalize_audio(word_wav)
93
+ if norm_word:
94
+ raw_segments.append({"audio": norm_word, "is_pause": False})
95
+ else:
96
+ phonemes = self.g2p(token)
97
+ valid_ps = [re.sub(r'\d+', '', p) if p != "AH0" else p for p in phonemes]
98
+ valid_ps = [p for p in valid_ps if re.match(r'[A-Z]+[0-9]*', p)]
99
+
100
+ if valid_ps and valid_ps[-1] in ["AH", "AE", "AH0"]:
101
+ valid_ps[-1] = random.choice(["AA", "AH"])
102
+
103
+ for p_clean in valid_ps:
104
+ seg_audio = self._get_phoneme_data(p_clean)
105
+ if seg_audio:
106
+ raw_segments.append({"audio": seg_audio, "is_pause": False})
107
+
108
+ raw_segments.append({"audio": AudioSegment.silent(duration=self.word_pause_ms), "is_pause": True})
109
+
110
+ if not raw_segments:
111
+ return AudioSegment.silent(duration=100)
112
+
113
+ final_audio = None
114
+ for i in range(len(raw_segments)):
115
+ curr_audio = raw_segments[i]["audio"]
116
+ if final_audio is None:
117
+ final_audio = curr_audio
118
+ continue
119
+
120
+ # Apply crossfade if neither side is a pause segment
121
+ if not raw_segments[i-1]["is_pause"] and not raw_segments[i]["is_pause"]:
122
+ fade_size = min(self.fade_duration_ms, len(final_audio), len(curr_audio))
123
+ if fade_size > 0:
124
+ final_audio = final_audio.append(curr_audio, crossfade=fade_size)
125
+ else:
126
+ final_audio += curr_audio
127
+ else:
128
+ final_audio += curr_audio
129
+
130
+ return final_audio
131
+
132
+ def render_to_file(self, str_input, output_path):
133
+ audio_segment = self.generate_audio_data(str_input)
134
+ audio_segment.export(output_path, format="wav")
135
+
136
+
137
+ # --- Helper functions for Managing Categories & ZIP uploads ---
138
+
139
+ def get_hierarchy():
140
+ """Scans the assets directory and returns structural mapping."""
141
+ categories = {}
142
+ if not os.path.isdir(BASE_CHARACTERS_DIR):
143
+ return categories
144
+ for cat in sorted(os.listdir(BASE_CHARACTERS_DIR)):
145
+ cat_p = os.path.join(BASE_CHARACTERS_DIR, cat)
146
+ if os.path.isdir(cat_p):
147
+ chars = [c for c in os.listdir(cat_p) if os.path.isdir(os.path.join(cat_p, c))]
148
+ if chars:
149
+ categories[cat] = sorted(chars)
150
+ return categories
151
+
152
+ def handle_zip_upload(file_obj):
153
+ """Unpacks zipped voice lines into the expected directory schema."""
154
+ if file_obj is None:
155
+ return gr.update(), gr.update(), "No file uploaded."
156
+
157
+ try:
158
+ temp_extract = os.path.join("assets", f"temp_{uuid.uuid4().hex[:6]}")
159
+ with zipfile.ZipFile(file_obj.name, 'r') as zip_ref:
160
+ zip_ref.extractall(temp_extract)
161
+
162
+ # Figure out internal structure and migrate valid directories
163
+ for root, dirs, files in os.walk(temp_extract):
164
+ # If directory contains wav files directly, treat it as a character folder
165
+ if any(f.lower().endswith('.wav') for f in files):
166
+ char_name = os.path.basename(root)
167
+ parent_name = os.path.basename(os.path.dirname(root))
168
+
169
+ # If parent folder is just the root temp extraction layout, assign a generic Category
170
+ category_name = parent_name if parent_name != os.path.basename(temp_extract) else "Uploaded"
171
+
172
+ dest_dir = os.path.join(BASE_CHARACTERS_DIR, category_name, char_name)
173
+ os.makedirs(os.path.dirname(dest_dir), exist_ok=True)
174
+ if os.path.exists(dest_dir):
175
+ shutil.rmtree(dest_dir)
176
+ shutil.copytree(root, dest_dir)
177
+
178
+ shutil.rmtree(temp_extract)
179
+
180
+ # Refresh configuration selections
181
+ hierarchy = get_hierarchy()
182
+ cats = list(hierarchy.keys())
183
+ default_cat = cats[0] if cats else None
184
+ default_chars = hierarchy[default_cat] if default_cat else []
185
+
186
+ return (
187
+ gr.update(choices=cats, value=default_cat),
188
+ gr.update(choices=default_chars, value=default_chars[0] if default_chars else None),
189
+ "Voice pack uploaded and cataloged successfully!"
190
+ )
191
+ except Exception as e:
192
+ return gr.update(), gr.update(), f"Error processing file: {str(e)}"
193
+
194
+ def update_characters(category):
195
+ hierarchy = get_hierarchy()
196
+ chars = hierarchy.get(category, [])
197
+ return gr.update(choices=chars, value=chars[0] if chars else None)
198
+
199
+ def update_profile_preview(category, character):
200
+ if not category or not character:
201
+ return None
202
+ profile_path = os.path.join(BASE_CHARACTERS_DIR, category, character, "profile.png")
203
+ if os.path.exists(profile_path):
204
+ return profile_path
205
+ return None
206
+
207
+ def synthesize(category, character, text):
208
+ if not category or not character:
209
+ raise gr.Error("Please ensure a valid Category and Character are active.")
210
+ if not text.strip():
211
+ raise gr.Error("Text field cannot be left blank.")
212
+
213
+ char_path = os.path.join(BASE_CHARACTERS_DIR, category, character)
214
+ tts = TextToSpeech(char_path)
215
+
216
+ out_filename = f"output_{uuid.uuid4().hex[:8]}.wav"
217
+ tts.render_to_file(text, out_filename)
218
+ return out_filename
219
+
220
+
221
+ # --- Gradio UI Block Setup ---
222
+
223
+ initial_hierarchy = get_hierarchy()
224
+ initial_cats = list(initial_hierarchy.keys())
225
+ initial_chars = initial_hierarchy[initial_cats[0]] if initial_cats else []
226
+
227
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="amber", neutral_hue="slate")) as demo:
228
+ gr.Markdown("# 🎙️ Sentence Mixing TTS Generator")
229
+ gr.Markdown("An elegant web interface for sentence-mixing speech generation. Upload voice line assets or choose a character configuration to begin.")
230
+
231
+ with gr.Row():
232
+ with gr.Column(scale=1):
233
+ profile_preview = gr.Image(
234
+ value=update_profile_preview(initial_cats[0], initial_chars[0]) if initial_chars else None,
235
+ label="Character Profile",
236
+ height=220,
237
+ width=220,
238
+ interactive=False,
239
+ circle=True
240
+ )
241
+
242
+ category_drop = gr.Dropdown(choices=initial_cats, value=initial_cats[0] if initial_cats else None, label="Voice Category")
243
+ character_drop = gr.Dropdown(choices=initial_chars, value=initial_chars[0] if initial_chars else None, label="Character")
244
+
245
+ category_drop.change(update_characters, inputs=category_drop, outputs=character_drop)
246
+ character_drop.change(update_profile_preview, inputs=[category_drop, character_drop], outputs=profile_preview)
247
+
248
+ with gr.Column(scale=2):
249
+ input_text = gr.Textbox(label="Text to Synthesize", lines=6, placeholder="Type your text sentence here...")
250
+ submit_btn = gr.Button("📢 Speak / Generate", variant="primary")
251
+ audio_output = gr.Audio(label="Synthesized Audio Output", type="filepath")
252
+
253
+ submit_btn.click(synthesize, inputs=[category_drop, character_drop, input_text], outputs=audio_output)
254
+
255
+ with gr.Accordion("⚙️ Upload New Voice Assets (.zip)", open=False):
256
+ gr.Markdown("""
257
+ ### Expected `.zip` Internal Structure
258
+ You can pack folders into your zip file. For example:
259
+ * `MyCharacter/AA.wav`, `MyCharacter/B.wav`, etc.
260
+ * `MyCharacter/words/HELLO.wav` (Optional)
261
+ * `MyCharacter/profile.png` (Optional round-cropped display icon)
262
+ """)
263
+ zip_uploader = gr.File(label="Choose Voice Zip File", file_types=[".zip"])
264
+ upload_status = gr.Markdown(value="Waiting for file upload...")
265
+ upload_btn = gr.Button("📦 Unpack & Register Voice Pack")
266
+
267
+ upload_btn.click(
268
+ handle_zip_upload,
269
+ inputs=zip_uploader,
270
+ outputs=[category_drop, character_drop, upload_status]
271
+ )
272
+
273
+ if __name__ == "__main__":
274
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio
2
+ g2p_en
3
+ pydub
4
+ nltk