Alstears commited on
Commit
6b11b7f
·
verified ·
1 Parent(s): fbddbcf

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +93 -143
app.py CHANGED
@@ -1,5 +1,5 @@
1
  import os
2
- # Paksa PyTorch agar hanya melihat CPU dan matikan CUDA murni sebelum library lain di-import
3
  os.environ["CUDA_VISIBLE_DEVICES"] = ""
4
 
5
  import re
@@ -7,6 +7,7 @@ import gc
7
  import inspect
8
  import tempfile
9
  import traceback
 
10
  from threading import Lock
11
 
12
  import requests
@@ -14,48 +15,41 @@ import torch
14
  import torchaudio as ta
15
  import gradio as gr
16
 
17
- # =====================================================================
18
- # OPTIMASI EKSTREM LEVEL CPU & PYTORCH (ANTI-NGARET & DEPREKASI)
19
- # =====================================================================
20
- # Batasi thread PyTorch secara agresif agar tidak rebutan core di shared CPU
21
  torch.set_num_threads(2)
22
  torch.set_num_interop_threads(2)
23
-
24
- # Matikan kalkulasi gradient secara global karena ini murni inference/synthesis
25
  torch.set_grad_enabled(False)
26
-
27
- # Hard patch untuk mendepresiasi CUDA di library pihak ketiga
28
  torch.cuda.is_available = lambda: False # noqa: E731
29
 
 
30
  _original_torch_load = torch.load
31
  def _torch_load_cpu(*args, **kwargs):
32
  kwargs["map_location"] = torch.device("cpu")
33
  return _original_torch_load(*args, **kwargs)
34
  torch.load = _torch_load_cpu
35
 
36
- if hasattr(torch.jit, "load"):
37
- _original_jit_load = torch.jit.load
38
- def _jit_load_cpu(*args, **kwargs):
39
- kwargs["map_location"] = torch.device("cpu")
40
- return _original_jit_load(*args, **kwargs)
41
- torch.jit.load = _jit_load_cpu
42
-
43
  # =====================================================================
44
- # CONFIG (BATASAN REASONS & KONTROL MEMORI)
45
  # =====================================================================
46
  MODEL_REPO = "grandhigh/Chatterbox-TTS-Indonesian"
47
  CHECKPOINT_FILENAME = "t3_cfg.safetensors"
48
  DEVICE = "cpu"
49
 
50
- MAX_TOTAL_CHARS = int(os.getenv("MAX_TOTAL_CHARS", "2400")) # total karakter per request
51
- MAX_CHARS_PER_CHUNK = int(os.getenv("MAX_CHARS_PER_CHUNK", "220")) # karakter per chunk
52
- MAX_CHUNKS = int(os.getenv("MAX_CHUNKS", "12")) # maksimal jumlah chunk
53
- PAUSE_SECONDS = float(os.getenv("PAUSE_SECONDS", "0.15")) # jeda antar chunk
54
  DOWNLOAD_TIMEOUT = int(os.getenv("DOWNLOAD_TIMEOUT", "90"))
55
 
56
- # =====================================================================
57
- # MODEL IMPORT & SINGLETON LAZY LOADING
58
- # =====================================================================
 
 
 
 
 
 
59
  from chatterbox.tts import ChatterboxTTS
60
  from huggingface_hub import hf_hub_download
61
  from safetensors.torch import load_file
@@ -63,7 +57,6 @@ from safetensors.torch import load_file
63
  _model = None
64
  _model_lock = Lock()
65
 
66
-
67
  def get_model():
68
  global _model
69
  if _model is None:
@@ -87,61 +80,47 @@ def get_model():
87
  return _model
88
 
89
 
90
- def _download_wav(url: str) -> str:
91
- r = requests.get(url, timeout=DOWNLOAD_TIMEOUT)
92
- r.raise_for_status()
93
-
94
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
95
- tmp.write(r.content)
96
- tmp.close()
97
- return tmp.name
98
-
99
-
100
  def _resolve_audio_input(audio_file, audio_url: str):
101
  if isinstance(audio_file, str) and audio_file.strip():
102
  return audio_file
103
-
104
  if isinstance(audio_file, dict):
105
  p = audio_file.get("path")
106
  if p:
107
  return p
108
-
109
  if audio_url and audio_url.strip():
110
- return _download_wav(audio_url.strip())
111
-
 
 
 
 
 
 
 
 
 
 
 
 
112
  return None
113
 
114
 
115
- def _prepare_text_exact(text: str) -> str:
116
- t = re.sub(r"\s+", " ", (text or "").strip())
117
- if not t:
118
- raise gr.Error("Text prompt tidak boleh kosong.")
119
- if not re.search(r"[.!?…]$", t):
120
- t += "."
121
- return t
122
-
123
-
124
  def _split_text_safely(text: str, max_chars: int = MAX_CHARS_PER_CHUNK):
125
  text = re.sub(r"\s+", " ", (text or "").strip())
126
  if not text:
127
  return []
128
-
129
  sentences = re.split(r"(?<=[.!?])\s+", text)
130
  chunks = []
131
  current = ""
132
-
133
  for s in sentences:
134
  s = s.strip()
135
  if not s:
136
  continue
137
-
138
  parts = [s] if len(s) <= max_chars else re.split(r"(?<=[,;:])\s+", s)
139
-
140
  for p in parts:
141
  p = p.strip()
142
  if not p:
143
  continue
144
-
145
  if len(p) > max_chars:
146
  words = p.split()
147
  tmp = ""
@@ -156,7 +135,6 @@ def _split_text_safely(text: str, max_chars: int = MAX_CHARS_PER_CHUNK):
156
  if tmp:
157
  chunks.append(tmp)
158
  continue
159
-
160
  candidate = f"{current} {p}".strip() if current else p
161
  if len(candidate) <= max_chars:
162
  current = candidate
@@ -164,108 +142,100 @@ def _split_text_safely(text: str, max_chars: int = MAX_CHARS_PER_CHUNK):
164
  if current:
165
  chunks.append(current)
166
  current = p
167
-
168
  if current:
169
  chunks.append(current)
170
-
171
  return chunks
172
 
173
-
174
- def _generate_with_safe_kwargs(model, text: str, prompt_path: str):
175
- sig = inspect.signature(model.generate)
176
- params = sig.parameters
177
- kwargs = {}
178
-
179
- if "audio_prompt_path" in params:
180
- kwargs["audio_prompt_path"] = prompt_path
181
-
182
- if "temperature" in params:
183
- kwargs["temperature"] = 0.05
184
- if "top_p" in params:
185
- kwargs["top_p"] = 0.7
186
- if "exaggeration" in params:
187
- kwargs["exaggeration"] = 0.25
188
- if "cfg_weight" in params:
189
- kwargs["cfg_weight"] = 0.3
190
- if "max_new_tokens" in params:
191
- kwargs["max_new_tokens"] = 260 # Kunci agar model tidak runaway loop tokens
192
-
193
- try:
194
- return model.generate(text, **kwargs)
195
- except TypeError:
196
- if "text" in params:
197
- kwargs["text"] = text
198
- return model.generate(**kwargs)
199
- return model.generate(text)
200
-
201
  # =====================================================================
202
- # MAIN INFERENCE ENGINE (REFACTORED WITH INFERENCE_MODE)
203
  # =====================================================================
204
  def clone_voice(text: str, audio_file, audio_url: str, progress=gr.Progress(track_tqdm=False)):
 
205
  try:
206
  raw_text = (text or "").strip()
207
  if not raw_text:
208
  raise gr.Error("Text prompt tidak boleh kosong.")
209
 
210
- if len(raw_text) > MAX_TOTAL_CHARS:
211
- raise gr.Error(
212
- f"Teks terlalu panjang ({len(raw_text)} karakter). "
213
- f"Maksimal {MAX_TOTAL_CHARS} karakter per request."
214
- )
215
-
216
  prompt_path = _resolve_audio_input(audio_file, audio_url)
217
  if not prompt_path:
218
- raise gr.Error("Upload WAV atau isi Audio URL WAV.")
219
 
220
  chunks = _split_text_safely(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
221
- if not chunks:
222
- raise gr.Error("Gagal memproses teks (chunk kosong).")
223
-
224
- # FILTER SMART: Buang fragmen chunk yang tidak mengandung karakter alfanumerik murni
225
  chunks = [ch for ch in chunks if re.search(r'[a-zA-Z0-9]', ch)]
226
 
227
- if len(chunks) > MAX_CHUNKS:
228
- raise gr.Error(
229
- f"Teks terlalu padat ({len(chunks)} chunk). "
230
- f"Maksimal {MAX_CHUNKS} chunk per request."
231
- )
232
-
233
  model = get_model()
234
  sr = getattr(model, "sr", 24000)
235
-
236
- # Kunci manual seed di luar perulangan
237
  torch.manual_seed(42)
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  wav_parts = []
240
  pause = torch.zeros(1, int(sr * PAUSE_SECONDS))
241
-
242
  total = len(chunks)
243
 
244
- # MANFAATKAN INFERENCE MODE (Jauh lebih hemat RAM & cepat dibanding no_grad)
 
 
245
  with torch.inference_mode():
246
  for i, ch in enumerate(chunks, start=1):
247
- progress((i - 1) / total, desc=f"Memproses chunk biner {i}/{total}...")
248
- ch = _prepare_text_exact(ch)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
 
250
- wav = _generate_with_safe_kwargs(model, ch, prompt_path)
251
  if wav.dim() == 1:
252
  wav = wav.unsqueeze(0)
253
 
254
- # Segera pindahkan hasil tensor ke penampung cpu bersih untuk memotong graph memori
255
  wav_parts.append(wav.detach().cpu().clone())
256
  wav_parts.append(pause)
257
 
258
- # Buang elemen pause buatan di bagian akhir audio tracker
259
  if wav_parts:
260
  wav_parts = wav_parts[:-1]
261
 
262
- progress(0.95, desc="Menggabungkan seluruh komponen bytes audio...")
263
  full_wav = torch.cat(wav_parts, dim=1)
264
 
265
  out_path = tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name
266
  ta.save(out_path, full_wav, sr)
267
 
268
- # PEMBERSIHAN AGRESIF: Paksa Garbage Collector Python membuang sisa array tensor
269
  del wav_parts
270
  del full_wav
271
  gc.collect()
@@ -279,37 +249,18 @@ def clone_voice(text: str, audio_file, audio_url: str, progress=gr.Progress(trac
279
  raise gr.Error(f"Gagal generate audio: {e}")
280
 
281
  # =====================================================================
282
- # GRADIO INTERFACE DESIGN
283
  # =====================================================================
284
- with gr.Blocks(title="Chatterbox Indonesian Voice Cloning (CPU Optimized)") as demo:
285
- gr.Markdown("## EduScanner AI Voice Engine - Chatterbox CPU Optimized")
286
- gr.Markdown(
287
- f"""
288
- **Spesifikasi Keamanan Sumber Daya (Hugging Face Shared CPU Tier):**
289
- - Batas Maksimal Karakter Masuk: **{MAX_TOTAL_CHARS}** karakter.
290
- - Pembagian Kapasitas per Chunk: **{MAX_CHARS_PER_CHUNK}** karakter.
291
- - Manajemen Batas Atas Antrean Fragmentasi: **{MAX_CHUNKS}** chunk.
292
- """
293
- )
294
-
295
- text_in = gr.Textbox(
296
- label="Teks Akademik / Rangkuman (Maks 2400 Karakter)",
297
- lines=8,
298
- placeholder="Masukkan teks rangkuman materi perkuliahan di sini untuk diubah menjadi suara kloning..."
299
- )
300
-
301
- wav_in = gr.Audio(
302
- label="Sampel Suara Target (Upload WAV)",
303
- type="filepath"
304
- )
305
-
306
- url_in = gr.Textbox(
307
- label="Atau Gunakan URL Sampel Audio WAV (Opsional)",
308
- placeholder="https://domain-kamu.com/assets/sample_suara.wav"
309
- )
310
 
311
- btn = gr.Button("Mulai Sintesis Suara", variant="primary")
312
- out_audio = gr.Audio(label="Hasil Audio Hasil Kloning (WAV)", type="filepath")
 
 
 
 
313
 
314
  btn.click(
315
  fn=clone_voice,
@@ -320,6 +271,5 @@ with gr.Blocks(title="Chatterbox Indonesian Voice Cloning (CPU Optimized)") as d
320
 
321
  if __name__ == "__main__":
322
  port = int(os.getenv("PORT", "7860"))
323
- # default_concurrency_limit=1 memaksa antrean global agar antarmuka tidak crash kelebihan beban
324
  demo.queue(default_concurrency_limit=1)
325
  demo.launch(server_name="0.0.0.0", server_port=port, show_error=True)
 
1
  import os
2
+ # Paksa PyTorch murni berjalan di CPU sebelum library lain masuk
3
  os.environ["CUDA_VISIBLE_DEVICES"] = ""
4
 
5
  import re
 
7
  import inspect
8
  import tempfile
9
  import traceback
10
+ from pathlib import Path
11
  from threading import Lock
12
 
13
  import requests
 
15
  import torchaudio as ta
16
  import gradio as gr
17
 
18
+ # Optimasi Core CPU shared Hugging Face agar fokus
 
 
 
19
  torch.set_num_threads(2)
20
  torch.set_num_interop_threads(2)
 
 
21
  torch.set_grad_enabled(False)
 
 
22
  torch.cuda.is_available = lambda: False # noqa: E731
23
 
24
+ # Override global torch load untuk keamanan CPU
25
  _original_torch_load = torch.load
26
  def _torch_load_cpu(*args, **kwargs):
27
  kwargs["map_location"] = torch.device("cpu")
28
  return _original_torch_load(*args, **kwargs)
29
  torch.load = _torch_load_cpu
30
 
 
 
 
 
 
 
 
31
  # =====================================================================
32
+ # CONFIG & PATH MANAGEMENT
33
  # =====================================================================
34
  MODEL_REPO = "grandhigh/Chatterbox-TTS-Indonesian"
35
  CHECKPOINT_FILENAME = "t3_cfg.safetensors"
36
  DEVICE = "cpu"
37
 
38
+ MAX_TOTAL_CHARS = int(os.getenv("MAX_TOTAL_CHARS", "2400"))
39
+ MAX_CHARS_PER_CHUNK = int(os.getenv("MAX_CHARS_PER_CHUNK", "220"))
40
+ MAX_CHUNKS = int(os.getenv("MAX_CHUNKS", "12"))
41
+ PAUSE_SECONDS = float(os.getenv("PAUSE_SECONDS", "0.15"))
42
  DOWNLOAD_TIMEOUT = int(os.getenv("DOWNLOAD_TIMEOUT", "90"))
43
 
44
+ # Jalur untuk suara default bawaan sistem (Taruh file suara 1:15 kamu di folder assets Space ini)
45
+ ROOT_DIR = Path(__file__).parent
46
+ ASSETS_DIR = ROOT_DIR / "assets"
47
+ ASSETS_DIR.mkdir(exist_ok=True)
48
+ DEFAULT_SPEAKER_PATH = ASSETS_DIR / "default_speaker.wav"
49
+
50
+ # Global Cache untuk mengunci embedding suara agar tidak dihitung ulang setiap request
51
+ CACHED_EMBEDDINGS = {}
52
+
53
  from chatterbox.tts import ChatterboxTTS
54
  from huggingface_hub import hf_hub_download
55
  from safetensors.torch import load_file
 
57
  _model = None
58
  _model_lock = Lock()
59
 
 
60
  def get_model():
61
  global _model
62
  if _model is None:
 
80
  return _model
81
 
82
 
 
 
 
 
 
 
 
 
 
 
83
  def _resolve_audio_input(audio_file, audio_url: str):
84
  if isinstance(audio_file, str) and audio_file.strip():
85
  return audio_file
 
86
  if isinstance(audio_file, dict):
87
  p = audio_file.get("path")
88
  if p:
89
  return p
 
90
  if audio_url and audio_url.strip():
91
+ try:
92
+ r = requests.get(audio_url.strip(), timeout=DOWNLOAD_TIMEOUT)
93
+ r.raise_for_status()
94
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
95
+ tmp.write(r.content)
96
+ tmp.close()
97
+ return tmp.name
98
+ except Exception:
99
+ pass
100
+
101
+ # KUNCI UTAMA: Jika tidak ada file/url yang dikirim, otomatis alihkan ke Default Voice bawaan sistem
102
+ if DEFAULT_SPEAKER_PATH.exists():
103
+ return str(DEFAULT_SPEAKER_PATH)
104
+
105
  return None
106
 
107
 
 
 
 
 
 
 
 
 
 
108
  def _split_text_safely(text: str, max_chars: int = MAX_CHARS_PER_CHUNK):
109
  text = re.sub(r"\s+", " ", (text or "").strip())
110
  if not text:
111
  return []
 
112
  sentences = re.split(r"(?<=[.!?])\s+", text)
113
  chunks = []
114
  current = ""
 
115
  for s in sentences:
116
  s = s.strip()
117
  if not s:
118
  continue
 
119
  parts = [s] if len(s) <= max_chars else re.split(r"(?<=[,;:])\s+", s)
 
120
  for p in parts:
121
  p = p.strip()
122
  if not p:
123
  continue
 
124
  if len(p) > max_chars:
125
  words = p.split()
126
  tmp = ""
 
135
  if tmp:
136
  chunks.append(tmp)
137
  continue
 
138
  candidate = f"{current} {p}".strip() if current else p
139
  if len(candidate) <= max_chars:
140
  current = candidate
 
142
  if current:
143
  chunks.append(current)
144
  current = p
 
145
  if current:
146
  chunks.append(current)
 
147
  return chunks
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  # =====================================================================
150
+ # ENGINE INFERENCE DENGAN DUAL-SPEAKER SELECTION & EMBEDDING CACHE
151
  # =====================================================================
152
  def clone_voice(text: str, audio_file, audio_url: str, progress=gr.Progress(track_tqdm=False)):
153
+ global CACHED_EMBEDDINGS
154
  try:
155
  raw_text = (text or "").strip()
156
  if not raw_text:
157
  raise gr.Error("Text prompt tidak boleh kosong.")
158
 
159
+ # Tentukan file suara yang dipakai (Custom atau Default Lokal)
 
 
 
 
 
160
  prompt_path = _resolve_audio_input(audio_file, audio_url)
161
  if not prompt_path:
162
+ raise gr.Error("Suara default tidak ditemukan di server. Harap upload sampel audio.")
163
 
164
  chunks = _split_text_safely(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
 
 
 
 
165
  chunks = [ch for ch in chunks if re.search(r'[a-zA-Z0-9]', ch)]
166
 
 
 
 
 
 
 
167
  model = get_model()
168
  sr = getattr(model, "sr", 24000)
 
 
169
  torch.manual_seed(42)
170
 
171
+ # Caching Dinamis berdasarkan path audio agar multi-user/multi-voice tetap aman
172
+ if prompt_path not in CACHED_EMBEDDINGS:
173
+ progress(0.0, desc="Mengekstrak dan mengunci embedding karakteristik suara ke RAM...")
174
+
175
+ if hasattr(model, "extract_conditioning") or hasattr(model, "get_speaker_embedding"):
176
+ extract_fn = getattr(model, "extract_conditioning", getattr(model, "get_speaker_embedding", None))
177
+ CACHED_EMBEDDINGS[prompt_path] = extract_fn(prompt_path)
178
+ else:
179
+ CACHED_EMBEDDINGS[prompt_path] = prompt_path
180
+
181
+ print(f"[CACHE] Karakteristik untuk {prompt_path} berhasil dikunci.")
182
+
183
+ speaker_embedding = CACHED_EMBEDDINGS[prompt_path]
184
+
185
  wav_parts = []
186
  pause = torch.zeros(1, int(sr * PAUSE_SECONDS))
 
187
  total = len(chunks)
188
 
189
+ sig = inspect.signature(model.generate)
190
+ params = sig.parameters
191
+
192
  with torch.inference_mode():
193
  for i, ch in enumerate(chunks, start=1):
194
+ progress((i - 1) / total, desc=f"Memproses kloning materi chunk {i}/{total}...")
195
+
196
+ ch = re.sub(r"\s+", " ", ch.strip())
197
+ if not re.search(r"[.!?…]$", ch):
198
+ ch += "."
199
+
200
+ kwargs = {
201
+ "temperature": 0.05,
202
+ "top_p": 0.7,
203
+ "exaggeration": 0.25,
204
+ "cfg_weight": 0.3,
205
+ "max_new_tokens": 260
206
+ }
207
+
208
+ if "audio_prompt_path" in params:
209
+ kwargs["audio_prompt_path"] = speaker_embedding
210
+ elif "speaker_embedding" in params:
211
+ kwargs["speaker_embedding"] = speaker_embedding
212
+ else:
213
+ kwargs["audio_prompt_path"] = prompt_path
214
+
215
+ try:
216
+ wav = model.generate(ch, **kwargs)
217
+ except TypeError:
218
+ if "text" in params:
219
+ kwargs["text"] = ch
220
+ wav = model.generate(**kwargs)
221
+ else:
222
+ wav = model.generate(ch)
223
 
 
224
  if wav.dim() == 1:
225
  wav = wav.unsqueeze(0)
226
 
 
227
  wav_parts.append(wav.detach().cpu().clone())
228
  wav_parts.append(pause)
229
 
 
230
  if wav_parts:
231
  wav_parts = wav_parts[:-1]
232
 
233
+ progress(0.95, desc="Menggabungkan audio akhir...")
234
  full_wav = torch.cat(wav_parts, dim=1)
235
 
236
  out_path = tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name
237
  ta.save(out_path, full_wav, sr)
238
 
 
239
  del wav_parts
240
  del full_wav
241
  gc.collect()
 
249
  raise gr.Error(f"Gagal generate audio: {e}")
250
 
251
  # =====================================================================
252
+ # INTERFACE DESIGN
253
  # =====================================================================
254
+ with gr.Blocks(title="Chatterbox Dual Voice Hybrid Engine") as demo:
255
+ gr.Markdown("## EduScanner AI - Dual Voice Solution (Default & Custom)")
256
+ gr.Markdown("Otomatis menggunakan suara default jika input file dikosongkan.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
258
+ text_in = gr.Textbox(label="Teks Rangkuman", lines=8, placeholder="Masukkan teks di sini...")
259
+ wav_in = gr.Audio(label="Opsi Custom Voice (Kosongkan jika ingin pakai suara default Syahid)", type="filepath")
260
+ url_in = gr.Textbox(label="Opsi Link URL Audio Custom", placeholder="https://example.com/suara_lain.wav")
261
+
262
+ btn = gr.Button("Sintesis Audio", variant="primary")
263
+ out_audio = gr.Audio(label="Hasil Audio (WAV)", type="filepath")
264
 
265
  btn.click(
266
  fn=clone_voice,
 
271
 
272
  if __name__ == "__main__":
273
  port = int(os.getenv("PORT", "7860"))
 
274
  demo.queue(default_concurrency_limit=1)
275
  demo.launch(server_name="0.0.0.0", server_port=port, show_error=True)