J Z commited on
Commit
90f4bee
·
verified ·
1 Parent(s): 75ff2e0

Update tts.py

Browse files
Files changed (1) hide show
  1. tts.py +216 -41
tts.py CHANGED
@@ -1,24 +1,25 @@
1
  """
2
- TTS module - Local Orpheus TTS (finetuned) with transformers + SNAC.
3
- Uses named voices from the finetuned model.
4
- Official settings from orpheus_tts source code.
5
- No vLLM dependency - uses HuggingFace transformers for compatibility.
6
  """
7
  import os
8
  import time
9
  import wave
10
  import logging
 
11
  import numpy as np
12
  import torch
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
  TEMP_DIR = "/tmp/tts_output"
 
17
  SAMPLE_RATE = 24000
18
 
19
- # Voice options: tara, leah, jess, leo, dan, mia, zac, zoe
20
- # Leo is a natural male voice
21
- VOICE = os.environ.get("ORPHEUS_VOICE", "leo")
22
 
23
  # Special token IDs for Orpheus
24
  TOKEN_START_OF_HUMAN = 128259
@@ -35,6 +36,9 @@ AUDIO_CODE_OFFSET = 128266
35
  _model = None
36
  _tokenizer = None
37
  _snac_model = None
 
 
 
38
  _initialized = False
39
 
40
 
@@ -49,77 +53,233 @@ def ensure_temp_dir():
49
 
50
  def initialize():
51
  """
52
- Load Orpheus finetuned model, tokenizer, and SNAC decoder.
 
 
53
  Call once at startup.
54
  """
55
- global _model, _tokenizer, _snac_model, _initialized
 
56
 
57
  if _initialized:
58
  return
59
 
60
  t0 = time.time()
61
- logger.info(f"[{_ts()}] [TTS] Initializing Orpheus TTS (finetuned, voice: {VOICE})...")
62
 
63
  from transformers import AutoModelForCausalLM, AutoTokenizer
64
  from snac import SNAC
65
 
66
  # Determine dtype based on GPU capability
 
67
  if torch.cuda.is_available():
68
  capability = torch.cuda.get_device_capability()
69
  if capability[0] >= 8:
70
  dtype = torch.bfloat16
71
- logger.info(f"[{_ts()}] [TTS] GPU compute {capability[0]}.{capability[1]} - bfloat16")
72
  else:
73
  dtype = torch.float16
74
- logger.info(f"[{_ts()}] [TTS] GPU compute {capability[0]}.{capability[1]} - float16")
75
  else:
76
  dtype = torch.float32
77
- logger.info(f"[{_ts()}] [TTS] No GPU - float32 (will be slow)")
78
 
79
- # 1. Load tokenizer from finetuned model (NOT gated, no auth needed)
80
  t1 = time.time()
81
- model_name = "canopylabs/orpheus-3b-0.1-ft"
82
- _tokenizer = AutoTokenizer.from_pretrained(model_name)
83
- logger.info(f"[{_ts()}] [TTS] Tokenizer loaded: {time.time()-t1:.2f}s")
84
-
85
- # 2. Load finetuned model (NOT gated)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  t2 = time.time()
 
 
 
 
 
 
 
 
 
87
  _model = AutoModelForCausalLM.from_pretrained(
88
  model_name,
89
  torch_dtype=dtype,
90
  device_map="auto",
 
91
  )
92
  _model.eval()
93
- logger.info(f"[{_ts()}] [TTS] Model loaded: {time.time()-t2:.2f}s")
94
 
95
- # 3. Load SNAC decoder
96
- t3 = time.time()
97
- _snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval()
98
- if torch.cuda.is_available():
99
- _snac_model = _snac_model.cuda()
100
- logger.info(f"[{_ts()}] [TTS] SNAC loaded: {time.time()-t3:.2f}s")
101
 
102
  _initialized = True
103
- logger.info(f"[{_ts()}] [TTS] ✓ Ready in {time.time()-t0:.2f}s (voice: {VOICE})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
 
106
  def _build_prompt_tokens(text):
107
  """
108
- Build input tokens for Orpheus finetuned model.
109
- Format: [SOH] {voice}: {text} [EOT EOH]
 
 
 
 
 
 
110
  """
111
- prompt = f"{VOICE}: {text}"
112
- tokens = _tokenizer(prompt, return_tensors="pt")
113
- start = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
114
- end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
115
- input_ids = torch.cat([start, tokens.input_ids, end], dim=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  return input_ids.to(_model.device)
117
 
118
 
119
  def _decode_audio_tokens(generated_ids):
120
  """
121
  Extract audio tokens from generated output and decode with SNAC.
122
- Returns numpy audio array at 24kHz.
123
  """
124
  # Find last AUDIO_START token, crop after it
125
  indices = (generated_ids == TOKEN_AUDIO_START).nonzero(as_tuple=True)
@@ -170,7 +330,8 @@ def _decode_audio_tokens(generated_ids):
170
 
171
  def generate_audio(text: str, output_filename: str = None) -> str:
172
  """
173
- Generate speech from text. Returns path to wav file, or None on failure.
 
174
  """
175
  if not text or not text.strip():
176
  return None
@@ -198,18 +359,32 @@ def generate_audio(text: str, output_filename: str = None) -> str:
198
  attention_mask = torch.ones_like(input_ids)
199
  logger.info(f"[{_ts()}] [TTS] Prompt: {input_ids.shape[1]} tokens")
200
 
201
- # Generate - official settings from orpheus_tts source code
202
  t1 = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  with torch.no_grad():
204
  generated_ids = _model.generate(
205
  input_ids=input_ids,
206
  attention_mask=attention_mask,
207
- max_new_tokens=1200,
208
  do_sample=True,
209
- temperature=0.6,
210
- top_p=0.8,
211
- repetition_penalty=1.3,
212
  eos_token_id=TOKEN_AUDIO_END,
 
213
  )
214
  t2 = time.time()
215
  new_tokens = generated_ids.shape[1] - input_ids.shape[1]
 
1
  """
2
+ TTS module - Local Orpheus TTS with transformers + SNAC.
3
+ Supports voice cloning from reference audio + transcript (pretrained model).
4
+ Falls back to named voice with finetuned model if no reference available.
5
+ No vLLM dependency - uses HuggingFace transformers for maximum compatibility.
6
  """
7
  import os
8
  import time
9
  import wave
10
  import logging
11
+ import subprocess
12
  import numpy as np
13
  import torch
14
 
15
  logger = logging.getLogger(__name__)
16
 
17
  TEMP_DIR = "/tmp/tts_output"
18
+ VOICE_REF_DIR = "/app/assets"
19
  SAMPLE_RATE = 24000
20
 
21
+ # Fallback voice for finetuned model (if no reference audio)
22
+ FALLBACK_VOICE = os.environ.get("ORPHEUS_VOICE", "leo")
 
23
 
24
  # Special token IDs for Orpheus
25
  TOKEN_START_OF_HUMAN = 128259
 
36
  _model = None
37
  _tokenizer = None
38
  _snac_model = None
39
+ _ref_tokens = None # Pre-encoded reference audio SNAC tokens
40
+ _ref_transcript = None # Transcript of reference audio (REQUIRED for voice cloning)
41
+ _use_voice_cloning = False
42
  _initialized = False
43
 
44
 
 
53
 
54
  def initialize():
55
  """
56
+ Load Orpheus model, tokenizer, and SNAC decoder.
57
+ If reference audio + transcript found, uses pretrained model for voice cloning.
58
+ Otherwise, uses finetuned model with named voices.
59
  Call once at startup.
60
  """
61
+ global _model, _tokenizer, _snac_model, _ref_tokens, _ref_transcript
62
+ global _use_voice_cloning, _initialized
63
 
64
  if _initialized:
65
  return
66
 
67
  t0 = time.time()
68
+ logger.info(f"[{_ts()}] [TTS] Initializing local Orpheus TTS...")
69
 
70
  from transformers import AutoModelForCausalLM, AutoTokenizer
71
  from snac import SNAC
72
 
73
  # Determine dtype based on GPU capability
74
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
75
  if torch.cuda.is_available():
76
  capability = torch.cuda.get_device_capability()
77
  if capability[0] >= 8:
78
  dtype = torch.bfloat16
79
+ logger.info(f"[{_ts()}] [TTS] GPU compute {capability[0]}.{capability[1]} - using bfloat16")
80
  else:
81
  dtype = torch.float16
82
+ logger.info(f"[{_ts()}] [TTS] GPU compute {capability[0]}.{capability[1]} - using float16 (T4)")
83
  else:
84
  dtype = torch.float32
85
+ logger.info(f"[{_ts()}] [TTS] No GPU - using float32 (will be slow)")
86
 
87
+ # 1. Load SNAC decoder first (needed for reference audio encoding)
88
  t1 = time.time()
89
+ _snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval()
90
+ if torch.cuda.is_available():
91
+ _snac_model = _snac_model.cuda()
92
+ logger.info(f"[{_ts()}] [TTS] SNAC decoder loaded: {time.time()-t1:.2f}s")
93
+
94
+ # 2. Check for reference audio + transcript
95
+ _ref_transcript = _load_transcript()
96
+ ref_audio_path = _find_reference_audio()
97
+
98
+ if ref_audio_path and _ref_transcript:
99
+ # Voice cloning mode: use pretrained model
100
+ _use_voice_cloning = True
101
+ model_name = "canopylabs/orpheus-3b-0.1-pretrained"
102
+ hf_token = os.environ.get("HF_TOKEN", None)
103
+ if not hf_token:
104
+ logger.warning(f"[{_ts()}] [TTS] No HF_TOKEN! Pretrained model is gated, falling back to finetuned.")
105
+ _use_voice_cloning = False
106
+ model_name = "canopylabs/orpheus-3b-0.1-ft"
107
+ else:
108
+ logger.info(f"[{_ts()}] [TTS] Voice cloning mode: ref audio + transcript found")
109
+ logger.info(f"[{_ts()}] [TTS] Transcript: \"{_ref_transcript[:80]}...\"")
110
+ else:
111
+ # Named voice mode: use finetuned model
112
+ _use_voice_cloning = False
113
+ model_name = "canopylabs/orpheus-3b-0.1-ft"
114
+ if not ref_audio_path:
115
+ logger.info(f"[{_ts()}] [TTS] No reference audio found")
116
+ if not _ref_transcript:
117
+ logger.info(f"[{_ts()}] [TTS] No transcript found (voice_transcript.txt)")
118
+ logger.info(f"[{_ts()}] [TTS] Using finetuned model with voice: {FALLBACK_VOICE}")
119
+
120
+ # 3. Load tokenizer
121
  t2 = time.time()
122
+ token_kwargs = {}
123
+ if _use_voice_cloning:
124
+ hf_token = os.environ.get("HF_TOKEN")
125
+ token_kwargs["token"] = hf_token
126
+ _tokenizer = AutoTokenizer.from_pretrained(model_name, **token_kwargs)
127
+ logger.info(f"[{_ts()}] [TTS] Tokenizer loaded: {time.time()-t2:.2f}s")
128
+
129
+ # 4. Load model
130
+ t3 = time.time()
131
  _model = AutoModelForCausalLM.from_pretrained(
132
  model_name,
133
  torch_dtype=dtype,
134
  device_map="auto",
135
+ **token_kwargs,
136
  )
137
  _model.eval()
138
+ logger.info(f"[{_ts()}] [TTS] Model loaded ({model_name.split('/')[-1]}): {time.time()-t3:.2f}s")
139
 
140
+ # 5. Encode reference audio if voice cloning
141
+ if _use_voice_cloning and ref_audio_path:
142
+ _ref_tokens = _encode_reference_audio(ref_audio_path)
143
+ if _ref_tokens is None:
144
+ logger.warning(f"[{_ts()}] [TTS] Reference encoding failed, falling back to named voice")
145
+ _use_voice_cloning = False
146
 
147
  _initialized = True
148
+ mode = f"voice cloning ({len(_ref_tokens)} ref tokens)" if _use_voice_cloning else f"named voice ({FALLBACK_VOICE})"
149
+ logger.info(f"[{_ts()}] [TTS] ✓ Orpheus ready in {time.time()-t0:.2f}s | mode: {mode}")
150
+
151
+
152
+ def _load_transcript():
153
+ """Load transcript of reference audio from voice_transcript.txt."""
154
+ for d in [VOICE_REF_DIR, "/app"]:
155
+ for name in ["voice_transcript.txt", "transcript.txt"]:
156
+ path = os.path.join(d, name)
157
+ if os.path.exists(path):
158
+ text = open(path).read().strip()
159
+ if text:
160
+ return text
161
+ return None
162
+
163
+
164
+ def _find_reference_audio():
165
+ """Find reference audio file."""
166
+ for d in [VOICE_REF_DIR, "/app"]:
167
+ for name in ["voice.mp4", "voice.wav", "voice.mp3"]:
168
+ path = os.path.join(d, name)
169
+ if os.path.exists(path) and os.path.getsize(path) > 1024:
170
+ return path
171
+ return None
172
+
173
+
174
+ def _encode_reference_audio(ref_path):
175
+ """Encode reference audio to SNAC tokens for voice cloning."""
176
+ import librosa
177
+
178
+ logger.info(f"[{_ts()}] [TTS] Encoding reference: {ref_path}")
179
+ ensure_temp_dir()
180
+
181
+ # Extract to wav if needed
182
+ wav_path = ref_path
183
+ if not ref_path.endswith(".wav"):
184
+ wav_path = os.path.join(TEMP_DIR, "voice_ref.wav")
185
+ try:
186
+ subprocess.run([
187
+ "ffmpeg", "-y", "-i", ref_path,
188
+ "-ar", str(SAMPLE_RATE), "-ac", "1", "-f", "wav", wav_path
189
+ ], capture_output=True, check=True)
190
+ except Exception as e:
191
+ logger.error(f"[{_ts()}] [TTS] ffmpeg failed: {e}")
192
+ return None
193
+
194
+ try:
195
+ t0 = time.time()
196
+ audio_array, sr = librosa.load(wav_path, sr=SAMPLE_RATE)
197
+
198
+ # Limit to 15 seconds
199
+ max_samples = 15 * SAMPLE_RATE
200
+ if len(audio_array) > max_samples:
201
+ audio_array = audio_array[:max_samples]
202
+ logger.info(f"[{_ts()}] [TTS] Trimmed reference to 15s")
203
+
204
+ duration = len(audio_array) / SAMPLE_RATE
205
+ logger.info(f"[{_ts()}] [TTS] Reference audio: {duration:.1f}s")
206
+
207
+ # Encode with SNAC (matching official Colab code exactly)
208
+ waveform = torch.from_numpy(audio_array).unsqueeze(0)
209
+ waveform = waveform.to(dtype=torch.float32)
210
+ waveform = waveform.unsqueeze(0)
211
+
212
+ with torch.inference_mode():
213
+ codes = _snac_model.encode(waveform)
214
+
215
+ # Interleave SNAC codes into Orpheus token format
216
+ all_codes = []
217
+ for i in range(codes[0].shape[1]):
218
+ all_codes.append(codes[0][0][i].item() + AUDIO_CODE_OFFSET)
219
+ all_codes.append(codes[1][0][2 * i].item() + AUDIO_CODE_OFFSET + 4096)
220
+ all_codes.append(codes[2][0][4 * i].item() + AUDIO_CODE_OFFSET + (2 * 4096))
221
+ all_codes.append(codes[2][0][(4 * i) + 1].item() + AUDIO_CODE_OFFSET + (3 * 4096))
222
+ all_codes.append(codes[1][0][(2 * i) + 1].item() + AUDIO_CODE_OFFSET + (4 * 4096))
223
+ all_codes.append(codes[2][0][(4 * i) + 2].item() + AUDIO_CODE_OFFSET + (5 * 4096))
224
+ all_codes.append(codes[2][0][(4 * i) + 3].item() + AUDIO_CODE_OFFSET + (6 * 4096))
225
+
226
+ logger.info(f"[{_ts()}] [TTS] Reference encoded: {len(all_codes)} tokens ({time.time()-t0:.2f}s)")
227
+ return all_codes
228
+
229
+ except Exception as e:
230
+ logger.error(f"[{_ts()}] [TTS] Failed to encode reference: {e}", exc_info=True)
231
+ return None
232
 
233
 
234
  def _build_prompt_tokens(text):
235
  """
236
+ Build input tokens for Orpheus generation.
237
+
238
+ Voice cloning (pretrained model) - matches official Colab exactly:
239
+ [SOH] [transcript_tokens] [EOT EOH SOA AUDIO_START] [ref_audio_tokens] [AUDIO_END EOAI]
240
+ [SOH] [text_tokens] [EOT EOH]
241
+
242
+ Named voice (finetuned model):
243
+ [SOH] {voice}: {text} [EOT EOH]
244
  """
245
+ if _use_voice_cloning and _ref_tokens is not None:
246
+ # Voice cloning: pretrained model format (from official Colab)
247
+ start_tokens = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
248
+ end_tokens = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN, TOKEN_START_OF_AI, TOKEN_AUDIO_START]], dtype=torch.int64)
249
+ final_tokens = torch.tensor([[TOKEN_AUDIO_END, TOKEN_END_OF_AI]], dtype=torch.int64)
250
+
251
+ # Transcript tokens (REQUIRED - this is what we were missing before)
252
+ transcript_ids = _tokenizer(_ref_transcript, return_tensors="pt").input_ids
253
+
254
+ # Build reference conditioning: [SOH][transcript][EOT EOH SOA AUDIO_START][audio][AUDIO_END EOAI]
255
+ ref_input = torch.cat([
256
+ start_tokens,
257
+ transcript_ids,
258
+ end_tokens,
259
+ torch.tensor([_ref_tokens], dtype=torch.int64),
260
+ final_tokens
261
+ ], dim=1)
262
+
263
+ # Generation prompt: [SOH][text][EOT EOH]
264
+ gen_ids = _tokenizer(text, return_tensors="pt").input_ids
265
+ gen_end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
266
+
267
+ input_ids = torch.cat([ref_input, start_tokens, gen_ids, gen_end], dim=1)
268
+ else:
269
+ # Named voice: finetuned model format
270
+ prompt = f"{FALLBACK_VOICE}: {text}"
271
+ tokens = _tokenizer(prompt, return_tensors="pt")
272
+ start = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
273
+ end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
274
+ input_ids = torch.cat([start, tokens.input_ids, end], dim=1)
275
+
276
  return input_ids.to(_model.device)
277
 
278
 
279
  def _decode_audio_tokens(generated_ids):
280
  """
281
  Extract audio tokens from generated output and decode with SNAC.
282
+ Matches official Colab decode logic exactly.
283
  """
284
  # Find last AUDIO_START token, crop after it
285
  indices = (generated_ids == TOKEN_AUDIO_START).nonzero(as_tuple=True)
 
330
 
331
  def generate_audio(text: str, output_filename: str = None) -> str:
332
  """
333
+ Generate speech audio from text using local Orpheus.
334
+ Returns path to wav file, or None on failure.
335
  """
336
  if not text or not text.strip():
337
  return None
 
359
  attention_mask = torch.ones_like(input_ids)
360
  logger.info(f"[{_ts()}] [TTS] Prompt: {input_ids.shape[1]} tokens")
361
 
362
+ # Generate with settings matched to mode
363
  t1 = time.time()
364
+ if _use_voice_cloning:
365
+ # Official pretrained model settings from Colab
366
+ gen_kwargs = dict(
367
+ max_new_tokens=990,
368
+ temperature=0.5,
369
+ top_p=0.9,
370
+ repetition_penalty=1.1,
371
+ )
372
+ else:
373
+ # Official finetuned model settings
374
+ gen_kwargs = dict(
375
+ max_new_tokens=1200,
376
+ temperature=0.6,
377
+ top_p=0.95,
378
+ repetition_penalty=1.1,
379
+ )
380
+
381
  with torch.no_grad():
382
  generated_ids = _model.generate(
383
  input_ids=input_ids,
384
  attention_mask=attention_mask,
 
385
  do_sample=True,
 
 
 
386
  eos_token_id=TOKEN_AUDIO_END,
387
+ **gen_kwargs,
388
  )
389
  t2 = time.time()
390
  new_tokens = generated_ids.shape[1] - input_ids.shape[1]