codewithjarair commited on
Commit
699d73b
·
verified ·
1 Parent(s): a7a891a

Update voice_cloning_engine.py

Browse files
Files changed (1) hide show
  1. voice_cloning_engine.py +254 -118
voice_cloning_engine.py CHANGED
@@ -6,22 +6,62 @@ from pathlib import Path
6
  import tempfile
7
  import os
8
  import logging
9
- from typing import Optional, Union, List
10
  import warnings
11
- warnings.filterwarnings('ignore')
12
-
13
- # Handle numpy 2.0 compatibility
14
- if np.__version__.startswith('2.'):
15
- import numpy as np
16
- # Patch numpy functions if needed
17
- np.float_ = np.float64
18
 
 
 
19
  try:
20
- from chatterbox import ChatterboxTTS
21
- from chatterbox.utils import prepare_text
22
- except ImportError as e:
23
- logging.error(f"Failed to import chatterbox: {e}")
24
- logging.error("Please ensure chatterbox is properly installed")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  class VoiceCloningEngine:
27
  """
@@ -38,15 +78,38 @@ class VoiceCloningEngine:
38
  self.device = "cuda" if torch.cuda.is_available() else "cpu"
39
  self.model_name = model_name
40
  self.model = None
41
- self.sample_rate = 24000 # Default sample rate for chatterbox
42
- self.max_chunk_length = 300 # Maximum characters per chunk
43
- self._load_model()
44
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _load_model(self):
46
- """Load the Chatterbox model"""
 
 
 
 
 
 
 
 
47
  try:
48
  logging.info(f"Loading model {self.model_name} on {self.device}")
49
- self.model = ChatterboxTTS(self.model_name, device=self.device)
 
 
 
 
50
  logging.info("Model loaded successfully")
51
  except Exception as e:
52
  logging.error(f"Failed to load model: {e}")
@@ -54,159 +117,232 @@ class VoiceCloningEngine:
54
 
55
  def _split_text_into_chunks(self, text: str, max_chunk_size: int = 300) -> List[str]:
56
  """
57
- Split text into chunks of approximately max_chunk_size characters
58
-
59
- Args:
60
- text: Input text to split
61
- max_chunk_size: Maximum characters per chunk
62
-
63
- Returns:
64
- List of text chunks
65
  """
66
- # Split by sentences (simple approach)
67
- sentences = text.replace('!', '.').replace('?', '.').split('.')
68
- sentences = [s.strip() + '.' for s in sentences if s.strip()]
 
 
 
 
69
 
70
  chunks = []
71
  current_chunk = ""
72
 
73
  for sentence in sentences:
74
- if len(current_chunk) + len(sentence) <= max_chunk_size:
75
- current_chunk += sentence + " "
76
- else:
77
- if current_chunk:
78
- chunks.append(current_chunk.strip())
79
- # If a single sentence is longer than max_chunk_size, split it
80
- if len(sentence) > max_chunk_size:
81
- words = sentence.split()
82
- temp_chunk = ""
83
- for word in words:
84
- if len(temp_chunk) + len(word) <= max_chunk_size:
85
- temp_chunk += word + " "
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  else:
87
- chunks.append(temp_chunk.strip())
88
- temp_chunk = word + " "
89
- if temp_chunk:
90
- chunks.append(temp_chunk.strip())
 
 
 
91
  else:
92
- current_chunk = sentence + " "
 
 
93
 
94
  if current_chunk:
95
- chunks.append(current_chunk.strip())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  return chunks
98
 
99
- def _merge_audio_chunks(self, audio_chunks: List[np.ndarray]) -> np.ndarray:
100
- """
101
- Merge multiple audio chunks into a single audio array
102
-
103
- Args:
104
- audio_chunks: List of audio arrays
105
-
106
- Returns:
107
- Merged audio array
108
- """
109
  if not audio_chunks:
110
  return np.array([])
111
- return np.concatenate(audio_chunks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  def clone_voice(
114
  self,
115
  text: str,
116
- reference_audio: Union[str, np.ndarray],
117
  temperature: float = 0.7,
118
  seed: Optional[int] = None,
119
  top_p: float = 0.9,
120
  top_k: int = 50,
121
  max_chunk_size: int = 300,
122
- auto_chunk: bool = True
123
- ) -> tuple:
 
124
  """
125
  Clone voice and generate speech
126
-
127
- Args:
128
- text: Text to synthesize
129
- reference_audio: Path to reference audio or audio array
130
- temperature: Sampling temperature
131
- seed: Random seed for reproducibility
132
- top_p: Top-p sampling parameter
133
- top_k: Top-k sampling parameter
134
- max_chunk_size: Maximum characters per chunk
135
- auto_chunk: Whether to automatically chunk long text
136
-
137
- Returns:
138
- Tuple of (audio_array, sample_rate)
139
  """
140
  if self.model is None:
141
  raise RuntimeError("Model not loaded")
142
 
143
- # Set seed for reproducibility
144
  if seed is not None:
 
145
  torch.manual_seed(seed)
146
  if torch.cuda.is_available():
147
  torch.cuda.manual_seed(seed)
148
 
149
- # Load reference audio if path provided
150
- if isinstance(reference_audio, str):
151
- audio_array, sr = torchaudio.load(reference_audio)
152
- # Resample if necessary
153
- if sr != self.sample_rate:
154
- resampler = torchaudio.transforms.Resample(sr, self.sample_rate)
155
- audio_array = resampler(audio_array)
156
- reference_audio = audio_array.squeeze().numpy()
157
 
158
- # Determine if chunking is needed
159
  if auto_chunk and len(text) > max_chunk_size:
160
  chunks = self._split_text_into_chunks(text, max_chunk_size)
161
  audio_chunks = []
162
 
163
- for chunk in chunks:
164
- # Process each chunk
165
- audio = self.model.synthesize(
166
- text=chunk,
167
- reference_audio=reference_audio,
168
- temperature=temperature,
169
- top_p=top_p,
170
- top_k=top_k
171
- )
172
- audio_chunks.append(audio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
- # Merge chunks
175
- final_audio = self._merge_audio_chunks(audio_chunks)
 
 
176
  else:
177
- # Process as single chunk
178
- final_audio = self.model.synthesize(
179
  text=text,
180
- reference_audio=reference_audio,
181
  temperature=temperature,
182
  top_p=top_p,
183
  top_k=top_k
184
  )
 
 
 
 
 
185
 
186
- return final_audio, self.sample_rate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  def save_audio(self, audio_array: np.ndarray, sample_rate: int, output_path: str):
189
- """
190
- Save audio array to file
191
-
192
- Args:
193
- audio_array: Audio data
194
- sample_rate: Sample rate
195
- output_path: Output file path
196
- """
197
  sf.write(output_path, audio_array, sample_rate)
198
 
199
  def get_temp_audio_path(self, audio_array: np.ndarray, sample_rate: int) -> str:
200
- """
201
- Save audio to temporary file and return path
202
-
203
- Args:
204
- audio_array: Audio data
205
- sample_rate: Sample rate
206
-
207
- Returns:
208
- Path to temporary audio file
209
- """
210
  with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
211
  temp_path = f.name
212
  self.save_audio(audio_array, sample_rate, temp_path)
 
6
  import tempfile
7
  import os
8
  import logging
9
+ from typing import Optional, Union, List, Tuple
10
  import warnings
11
+ import sys
12
+ import importlib.util
 
 
 
 
 
13
 
14
+ # Check if chatterbox is installed
15
+ CHATTERBOX_AVAILABLE = False
16
  try:
17
+ # Try different import paths
18
+ try:
19
+ from chatterbox import ChatterboxTTS
20
+ from chatterbox.utils import prepare_text
21
+ CHATTERBOX_AVAILABLE = True
22
+ print("✅ Chatterbox TTS imported successfully")
23
+ except ImportError:
24
+ try:
25
+ from chatterbox_tts import ChatterboxTTS
26
+ from chatterbox_tts.utils import prepare_text
27
+ CHATTERBOX_AVAILABLE = True
28
+ print("✅ Chatterbox TTS (chatterbox_tts) imported successfully")
29
+ except ImportError:
30
+ print("⚠️ Chatterbox TTS not found, using mock engine for testing")
31
+ except Exception as e:
32
+ print(f"⚠️ Error importing Chatterbox: {e}")
33
+
34
+ warnings.filterwarnings('ignore')
35
+
36
+ # Mock TTS engine for testing when Chatterbox is not available
37
+ class MockChatterboxTTS:
38
+ """Mock TTS engine for development and testing"""
39
+
40
+ def __init__(self, model_name=None, device=None, use_cache=None):
41
+ self.model_name = model_name
42
+ self.device = device or "cpu"
43
+ self.sample_rate = 24000
44
+
45
+ def synthesize(self, text, reference_audio=None, **kwargs):
46
+ """Generate mock audio (sine wave) for testing"""
47
+ import numpy as np
48
+
49
+ # Generate a simple sine wave based on text length
50
+ duration = min(10, len(text) * 0.1) # Max 10 seconds
51
+ t = np.linspace(0, duration, int(self.sample_rate * duration))
52
+
53
+ # Create a simple tone that varies with text
54
+ frequency = 220 + (hash(text) % 100) # Base frequency around 220-320 Hz
55
+ audio = 0.5 * np.sin(2 * np.pi * frequency * t)
56
+
57
+ # Add some harmonics to make it sound more "speech-like"
58
+ audio += 0.25 * np.sin(4 * np.pi * frequency * t)
59
+ audio += 0.125 * np.sin(6 * np.pi * frequency * t)
60
+
61
+ # Normalize
62
+ audio = audio / np.max(np.abs(audio))
63
+
64
+ return audio.astype(np.float32)
65
 
66
  class VoiceCloningEngine:
67
  """
 
78
  self.device = "cuda" if torch.cuda.is_available() else "cpu"
79
  self.model_name = model_name
80
  self.model = None
81
+ self.sample_rate = 24000
82
+ self.max_chunk_length = 300
83
+ self.is_mock = not CHATTERBOX_AVAILABLE
84
 
85
+ try:
86
+ self._load_model()
87
+ except Exception as e:
88
+ logging.error(f"Failed to load model: {e}")
89
+ if not self.is_mock:
90
+ print(f"\n❌ Error: Could not load Chatterbox TTS model.")
91
+ print(f" Details: {e}")
92
+ print(f" Using mock TTS engine for testing.\n")
93
+ self.is_mock = True
94
+ self.model = MockChatterboxTTS()
95
+
96
  def _load_model(self):
97
+ """Load the Chatterbox model or create mock"""
98
+ if self.is_mock:
99
+ logging.info("Using mock TTS engine")
100
+ self.model = MockChatterboxTTS(
101
+ self.model_name,
102
+ device=self.device
103
+ )
104
+ return
105
+
106
  try:
107
  logging.info(f"Loading model {self.model_name} on {self.device}")
108
+ self.model = ChatterboxTTS(
109
+ self.model_name,
110
+ device=self.device,
111
+ use_cache=True
112
+ )
113
  logging.info("Model loaded successfully")
114
  except Exception as e:
115
  logging.error(f"Failed to load model: {e}")
 
117
 
118
  def _split_text_into_chunks(self, text: str, max_chunk_size: int = 300) -> List[str]:
119
  """
120
+ Split text into chunks intelligently
 
 
 
 
 
 
 
121
  """
122
+ import re
123
+
124
+ # Clean up text
125
+ text = re.sub(r'\s+', ' ', text).strip()
126
+
127
+ # Try to split by sentences first
128
+ sentences = re.split(r'(?<=[.!?])\s+', text)
129
 
130
  chunks = []
131
  current_chunk = ""
132
 
133
  for sentence in sentences:
134
+ # If a single sentence is too long, split it further
135
+ if len(sentence) > max_chunk_size:
136
+ # Split by phrases (commas, semicolons, etc.)
137
+ phrases = re.split(r'(?<=[,;:])\s+', sentence)
138
+ for phrase in phrases:
139
+ if len(current_chunk) + len(phrase) <= max_chunk_size:
140
+ if current_chunk:
141
+ current_chunk += " " + phrase
142
+ else:
143
+ current_chunk = phrase
144
+ else:
145
+ if current_chunk:
146
+ chunks.append(current_chunk)
147
+ # If phrase itself is too long, split by words
148
+ if len(phrase) > max_chunk_size:
149
+ words = phrase.split()
150
+ temp_chunk = ""
151
+ for word in words:
152
+ if len(temp_chunk) + len(word) + 1 <= max_chunk_size:
153
+ if temp_chunk:
154
+ temp_chunk += " " + word
155
+ else:
156
+ temp_chunk = word
157
+ else:
158
+ if temp_chunk:
159
+ chunks.append(temp_chunk)
160
+ temp_chunk = word
161
+ if temp_chunk:
162
+ current_chunk = temp_chunk
163
+ else:
164
+ current_chunk = ""
165
  else:
166
+ current_chunk = phrase
167
+ else:
168
+ if len(current_chunk) + len(sentence) <= max_chunk_size:
169
+ if current_chunk:
170
+ current_chunk += " " + sentence
171
+ else:
172
+ current_chunk = sentence
173
  else:
174
+ if current_chunk:
175
+ chunks.append(current_chunk)
176
+ current_chunk = sentence
177
 
178
  if current_chunk:
179
+ chunks.append(current_chunk)
180
+
181
+ # If no chunks were created (shouldn't happen), split by words
182
+ if not chunks:
183
+ words = text.split()
184
+ chunks = []
185
+ current_chunk = ""
186
+ for word in words:
187
+ if len(current_chunk) + len(word) + 1 <= max_chunk_size:
188
+ if current_chunk:
189
+ current_chunk += " " + word
190
+ else:
191
+ current_chunk = word
192
+ else:
193
+ if current_chunk:
194
+ chunks.append(current_chunk)
195
+ current_chunk = word
196
+ if current_chunk:
197
+ chunks.append(current_chunk)
198
 
199
  return chunks
200
 
201
+ def _merge_audio_chunks(self, audio_chunks: List[np.ndarray], crossfade: int = 50) -> np.ndarray:
202
+ """Merge audio chunks with crossfade"""
 
 
 
 
 
 
 
 
203
  if not audio_chunks:
204
  return np.array([])
205
+
206
+ if len(audio_chunks) == 1:
207
+ return audio_chunks[0]
208
+
209
+ merged = audio_chunks[0]
210
+
211
+ for chunk in audio_chunks[1:]:
212
+ if crossfade > 0 and len(merged) > crossfade and len(chunk) > crossfade:
213
+ fade_out = np.linspace(1, 0, crossfade)
214
+ fade_in = np.linspace(0, 1, crossfade)
215
+
216
+ merged[-crossfade:] *= fade_out
217
+ chunk[:crossfade] *= fade_in
218
+
219
+ merged = np.concatenate([merged, chunk])
220
+ else:
221
+ merged = np.concatenate([merged, chunk])
222
+
223
+ # Normalize to prevent clipping
224
+ max_val = np.max(np.abs(merged))
225
+ if max_val > 0:
226
+ merged = merged / max_val * 0.95
227
+
228
+ return merged
229
 
230
  def clone_voice(
231
  self,
232
  text: str,
233
+ reference_audio: Union[str, np.ndarray, Tuple[np.ndarray, int]],
234
  temperature: float = 0.7,
235
  seed: Optional[int] = None,
236
  top_p: float = 0.9,
237
  top_k: int = 50,
238
  max_chunk_size: int = 300,
239
+ auto_chunk: bool = True,
240
+ crossfade_chunks: bool = True
241
+ ) -> Tuple[np.ndarray, int]:
242
  """
243
  Clone voice and generate speech
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  """
245
  if self.model is None:
246
  raise RuntimeError("Model not loaded")
247
 
248
+ # Set seed
249
  if seed is not None:
250
+ np.random.seed(seed)
251
  torch.manual_seed(seed)
252
  if torch.cuda.is_available():
253
  torch.cuda.manual_seed(seed)
254
 
255
+ # Process reference audio
256
+ reference_array = self._process_reference_audio(reference_audio)
 
 
 
 
 
 
257
 
258
+ # Generate speech
259
  if auto_chunk and len(text) > max_chunk_size:
260
  chunks = self._split_text_into_chunks(text, max_chunk_size)
261
  audio_chunks = []
262
 
263
+ for i, chunk in enumerate(chunks):
264
+ logging.info(f"Processing chunk {i+1}/{len(chunks)}: {len(chunk)} chars")
265
+
266
+ try:
267
+ audio = self.model.synthesize(
268
+ text=chunk,
269
+ reference_audio=reference_array,
270
+ temperature=temperature,
271
+ top_p=top_p,
272
+ top_k=top_k
273
+ )
274
+
275
+ # Ensure audio is numpy array
276
+ if torch.is_tensor(audio):
277
+ audio = audio.cpu().numpy()
278
+
279
+ audio_chunks.append(audio)
280
+
281
+ except Exception as e:
282
+ logging.error(f"Error processing chunk {i+1}: {e}")
283
+ continue
284
+
285
+ if not audio_chunks:
286
+ raise RuntimeError("No audio chunks were generated")
287
 
288
+ final_audio = self._merge_audio_chunks(
289
+ audio_chunks,
290
+ crossfade=50 if crossfade_chunks else 0
291
+ )
292
  else:
293
+ audio = self.model.synthesize(
 
294
  text=text,
295
+ reference_audio=reference_array,
296
  temperature=temperature,
297
  top_p=top_p,
298
  top_k=top_k
299
  )
300
+
301
+ if torch.is_tensor(audio):
302
+ audio = audio.cpu().numpy()
303
+
304
+ final_audio = audio
305
 
306
+ return final_audio.astype(np.float32), self.sample_rate
307
+
308
+ def _process_reference_audio(self, reference_audio):
309
+ """Process reference audio input"""
310
+ if isinstance(reference_audio, str):
311
+ # Load audio file
312
+ audio_array, sr = torchaudio.load(reference_audio)
313
+ audio_array = audio_array.squeeze().numpy()
314
+
315
+ # Resample if needed
316
+ if sr != self.sample_rate:
317
+ import scipy.signal
318
+ resample_ratio = self.sample_rate / sr
319
+ new_length = int(len(audio_array) * resample_ratio)
320
+ audio_array = scipy.signal.resample(audio_array, new_length)
321
+
322
+ return audio_array
323
+
324
+ elif isinstance(reference_audio, tuple) and len(reference_audio) == 2:
325
+ audio_array, sr = reference_audio
326
+
327
+ if sr != self.sample_rate:
328
+ import scipy.signal
329
+ resample_ratio = self.sample_rate / sr
330
+ new_length = int(len(audio_array) * resample_ratio)
331
+ audio_array = scipy.signal.resample(audio_array, new_length)
332
+
333
+ return audio_array
334
+
335
+ else:
336
+ # Assume it's already a numpy array at correct sample rate
337
+ return reference_audio
338
 
339
  def save_audio(self, audio_array: np.ndarray, sample_rate: int, output_path: str):
340
+ """Save audio to file"""
341
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
 
 
 
 
 
 
342
  sf.write(output_path, audio_array, sample_rate)
343
 
344
  def get_temp_audio_path(self, audio_array: np.ndarray, sample_rate: int) -> str:
345
+ """Save to temp file and return path"""
 
 
 
 
 
 
 
 
 
346
  with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
347
  temp_path = f.name
348
  self.save_audio(audio_array, sample_rate, temp_path)