J Z commited on
Commit
99f2109
·
verified ·
1 Parent(s): 15597cb

Update tts.py

Browse files
Files changed (1) hide show
  1. tts.py +26 -152
tts.py CHANGED
@@ -1,22 +1,24 @@
1
  """
2
- TTS module - Local Orpheus TTS with transformers + SNAC.
3
- Supports voice cloning from a reference audio file.
4
  No vLLM dependency - uses HuggingFace transformers for maximum compatibility.
5
  """
6
  import os
7
  import time
8
  import wave
9
  import logging
10
- import subprocess
11
  import numpy as np
12
  import torch
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
  TEMP_DIR = "/tmp/tts_output"
17
- VOICE_REF_DIR = "/app/assets"
18
  SAMPLE_RATE = 24000
19
 
 
 
 
 
20
  # Special token IDs for Orpheus
21
  TOKEN_START_OF_HUMAN = 128259
22
  TOKEN_END_OF_TEXT = 128009
@@ -32,8 +34,6 @@ AUDIO_CODE_OFFSET = 128266
32
  _model = None
33
  _tokenizer = None
34
  _snac_model = None
35
- _ref_tokens = None # Pre-encoded reference audio tokens
36
- _ref_transcript = "" # Transcript of reference audio
37
  _initialized = False
38
 
39
 
@@ -48,17 +48,17 @@ def ensure_temp_dir():
48
 
49
  def initialize():
50
  """
51
- Load Orpheus model, tokenizer, and SNAC decoder.
52
- Also pre-encode reference audio for voice cloning.
53
  Call once at startup.
54
  """
55
- global _model, _tokenizer, _snac_model, _ref_tokens, _initialized
56
 
57
  if _initialized:
58
  return
59
 
60
  t0 = time.time()
61
- logger.info(f"[{_ts()}] [TTS] Initializing local Orpheus TTS...")
 
62
 
63
  from transformers import AutoModelForCausalLM, AutoTokenizer
64
  from snac import SNAC
@@ -77,15 +77,10 @@ def initialize():
77
  dtype = torch.float32
78
  logger.info(f"[{_ts()}] [TTS] No GPU - using float32 (will be slow)")
79
 
80
- # 1. Load tokenizer
81
  t1 = time.time()
82
- model_name = "canopylabs/orpheus-3b-0.1-pretrained"
83
- hf_token = os.environ.get("HF_TOKEN", None)
84
- if hf_token:
85
- logger.info(f"[{_ts()}] [TTS] HF_TOKEN found, will use for gated model auth")
86
- else:
87
- logger.warning(f"[{_ts()}] [TTS] No HF_TOKEN found! Gated model download will fail.")
88
- _tokenizer = AutoTokenizer.from_pretrained(model_name, token=hf_token)
89
  logger.info(f"[{_ts()}] [TTS] Tokenizer loaded: {time.time()-t1:.2f}s")
90
 
91
  # 2. Load model
@@ -94,7 +89,6 @@ def initialize():
94
  model_name,
95
  torch_dtype=dtype,
96
  device_map="auto",
97
- token=hf_token,
98
  )
99
  _model.eval()
100
  logger.info(f"[{_ts()}] [TTS] Model loaded: {time.time()-t2:.2f}s")
@@ -106,140 +100,20 @@ def initialize():
106
  _snac_model = _snac_model.cuda()
107
  logger.info(f"[{_ts()}] [TTS] SNAC decoder loaded: {time.time()-t3:.2f}s")
108
 
109
- # 4. Pre-encode reference audio for voice cloning
110
- _ref_tokens = _load_reference_audio()
111
-
112
- # 5. Load reference transcript if available (improves voice cloning quality)
113
- global _ref_transcript
114
- _ref_transcript = ""
115
- for d in [VOICE_REF_DIR, "/app"]:
116
- for name in ["voice_transcript.txt", "transcript.txt"]:
117
- path = os.path.join(d, name)
118
- if os.path.exists(path):
119
- _ref_transcript = open(path).read().strip()
120
- logger.info(f"[{_ts()}] [TTS] Reference transcript loaded: \"{_ref_transcript[:60]}...\"")
121
- break
122
- if _ref_transcript:
123
- break
124
- if not _ref_transcript and _ref_tokens:
125
- logger.info(f"[{_ts()}] [TTS] No transcript file found. Voice cloning may be less consistent.")
126
- logger.info(f"[{_ts()}] [TTS] Add voice_transcript.txt with the spoken text for better results.")
127
-
128
  _initialized = True
129
- logger.info(f"[{_ts()}] [TTS] ✓ Orpheus ready in {time.time()-t0:.2f}s (voice cloning: {_ref_tokens is not None})")
130
-
131
-
132
- def _load_reference_audio():
133
- """
134
- Find and encode reference audio for voice cloning.
135
- Looks for voice.mp4/voice.wav in assets and app root.
136
- """
137
- import librosa
138
-
139
- ref_path = None
140
- search_dirs = [VOICE_REF_DIR, "/app"]
141
- search_names = ["voice.mp4", "voice.wav", "voice.mp3"]
142
-
143
- for d in search_dirs:
144
- for name in search_names:
145
- candidate = os.path.join(d, name)
146
- if os.path.exists(candidate) and os.path.getsize(candidate) > 1024:
147
- ref_path = candidate
148
- break
149
- if ref_path:
150
- break
151
-
152
- if ref_path is None:
153
- logger.warning(f"[{_ts()}] [TTS] No reference audio found. Will use named voice fallback.")
154
- return None
155
-
156
- logger.info(f"[{_ts()}] [TTS] Loading reference audio: {ref_path}")
157
- ensure_temp_dir()
158
-
159
- # Extract audio to wav if not already wav
160
- wav_path = ref_path
161
- if not ref_path.endswith(".wav"):
162
- wav_path = os.path.join(TEMP_DIR, "voice_ref.wav")
163
- try:
164
- subprocess.run([
165
- "ffmpeg", "-y", "-i", ref_path,
166
- "-ar", str(SAMPLE_RATE), "-ac", "1", "-f", "wav", wav_path
167
- ], capture_output=True, check=True)
168
- logger.info(f"[{_ts()}] [TTS] Extracted audio from {os.path.basename(ref_path)}")
169
- except Exception as e:
170
- logger.error(f"[{_ts()}] [TTS] ffmpeg extraction failed: {e}")
171
- return None
172
-
173
- try:
174
- t0 = time.time()
175
- audio_array, sr = librosa.load(wav_path, sr=SAMPLE_RATE)
176
-
177
- # Limit to 15 seconds for reference (keeps prompt length manageable)
178
- max_samples = 15 * SAMPLE_RATE
179
- if len(audio_array) > max_samples:
180
- audio_array = audio_array[:max_samples]
181
- logger.info(f"[{_ts()}] [TTS] Trimmed reference to 15s")
182
-
183
- duration = len(audio_array) / SAMPLE_RATE
184
- logger.info(f"[{_ts()}] [TTS] Reference audio: {duration:.1f}s")
185
-
186
- waveform = torch.from_numpy(audio_array).unsqueeze(0).unsqueeze(0).float()
187
- if torch.cuda.is_available():
188
- waveform = waveform.cuda()
189
-
190
- with torch.inference_mode():
191
- codes = _snac_model.encode(waveform)
192
-
193
- # Interleave SNAC codes into Orpheus token format
194
- all_codes = []
195
- for i in range(codes[0].shape[1]):
196
- all_codes.append(codes[0][0][i].item() + AUDIO_CODE_OFFSET)
197
- all_codes.append(codes[1][0][2 * i].item() + AUDIO_CODE_OFFSET + 4096)
198
- all_codes.append(codes[2][0][4 * i].item() + AUDIO_CODE_OFFSET + (2 * 4096))
199
- all_codes.append(codes[2][0][(4 * i) + 1].item() + AUDIO_CODE_OFFSET + (3 * 4096))
200
- all_codes.append(codes[1][0][(2 * i) + 1].item() + AUDIO_CODE_OFFSET + (4 * 4096))
201
- all_codes.append(codes[2][0][(4 * i) + 2].item() + AUDIO_CODE_OFFSET + (5 * 4096))
202
- all_codes.append(codes[2][0][(4 * i) + 3].item() + AUDIO_CODE_OFFSET + (6 * 4096))
203
-
204
- logger.info(f"[{_ts()}] [TTS] Reference encoded: {len(all_codes)} tokens ({time.time()-t0:.2f}s)")
205
- return all_codes
206
-
207
- except Exception as e:
208
- logger.error(f"[{_ts()}] [TTS] Failed to encode reference: {e}", exc_info=True)
209
- return None
210
 
211
 
212
  def _build_prompt_tokens(text):
213
  """
214
- Build input tokens for Orpheus generation.
215
- With voice cloning: reference audio tokens + text prompt.
216
- Without: named voice format.
217
  """
218
- if _ref_tokens is not None:
219
- # Voice cloning: [SOH][text][EOT EOH SOA AUDIO_START][ref_audio][AUDIO_END EOAI][SOH][gen_text][EOT EOH]
220
- start = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
221
- end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN, TOKEN_START_OF_AI, TOKEN_AUDIO_START]], dtype=torch.int64)
222
- final = torch.tensor([[TOKEN_AUDIO_END, TOKEN_END_OF_AI]], dtype=torch.int64)
223
-
224
- ref_text = _tokenizer(_ref_transcript, return_tensors="pt").input_ids
225
- ref_input = torch.cat([
226
- start, ref_text, end,
227
- torch.tensor([_ref_tokens], dtype=torch.int64),
228
- final
229
- ], dim=1)
230
-
231
- gen_text = _tokenizer(text, return_tensors="pt").input_ids
232
- gen_end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
233
-
234
- input_ids = torch.cat([ref_input, start, gen_text, gen_end], dim=1)
235
- else:
236
- # Named voice format for finetuned model
237
- prompt = f"tara: {text}"
238
- tokens = _tokenizer(prompt, return_tensors="pt")
239
- start = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
240
- end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
241
- input_ids = torch.cat([start, tokens.input_ids, end], dim=1)
242
-
243
  return input_ids.to(_model.device)
244
 
245
 
@@ -297,7 +171,7 @@ def _decode_audio_tokens(generated_ids):
297
 
298
  def generate_audio(text: str, output_filename: str = None) -> str:
299
  """
300
- Generate speech audio from text using local Orpheus.
301
  Returns path to wav file, or None on failure.
302
  """
303
  if not text or not text.strip():
@@ -326,17 +200,17 @@ def generate_audio(text: str, output_filename: str = None) -> str:
326
  attention_mask = torch.ones_like(input_ids)
327
  logger.info(f"[{_ts()}] [TTS] Prompt: {input_ids.shape[1]} tokens")
328
 
329
- # Generate
330
  t1 = time.time()
331
  with torch.no_grad():
332
  generated_ids = _model.generate(
333
  input_ids=input_ids,
334
  attention_mask=attention_mask,
335
- max_new_tokens=2048,
336
  do_sample=True,
337
  temperature=0.6,
338
- top_p=0.9,
339
- repetition_penalty=1.2,
340
  eos_token_id=TOKEN_AUDIO_END,
341
  )
342
  t2 = time.time()
 
1
  """
2
+ TTS module - Local Orpheus TTS (finetuned) with transformers + SNAC.
3
+ Uses named voices (leo, tara, etc.) from the finetuned model.
4
  No vLLM dependency - uses HuggingFace transformers for maximum compatibility.
5
  """
6
  import os
7
  import time
8
  import wave
9
  import logging
 
10
  import numpy as np
11
  import torch
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
  TEMP_DIR = "/tmp/tts_output"
 
16
  SAMPLE_RATE = 24000
17
 
18
+ # Default voice - options: tara, leah, jess, leo, dan, mia, zac, zoe
19
+ # Leo is the most natural-sounding male voice per Orpheus docs
20
+ VOICE = os.environ.get("ORPHEUS_VOICE", "leo")
21
+
22
  # Special token IDs for Orpheus
23
  TOKEN_START_OF_HUMAN = 128259
24
  TOKEN_END_OF_TEXT = 128009
 
34
  _model = None
35
  _tokenizer = None
36
  _snac_model = None
 
 
37
  _initialized = False
38
 
39
 
 
48
 
49
  def initialize():
50
  """
51
+ Load Orpheus finetuned model, tokenizer, and SNAC decoder.
 
52
  Call once at startup.
53
  """
54
+ global _model, _tokenizer, _snac_model, _initialized
55
 
56
  if _initialized:
57
  return
58
 
59
  t0 = time.time()
60
+ logger.info(f"[{_ts()}] [TTS] Initializing local Orpheus TTS (finetuned)...")
61
+ logger.info(f"[{_ts()}] [TTS] Voice: {VOICE}")
62
 
63
  from transformers import AutoModelForCausalLM, AutoTokenizer
64
  from snac import SNAC
 
77
  dtype = torch.float32
78
  logger.info(f"[{_ts()}] [TTS] No GPU - using float32 (will be slow)")
79
 
80
+ # 1. Load tokenizer - finetuned model is NOT gated, no auth needed
81
  t1 = time.time()
82
+ model_name = "canopylabs/orpheus-3b-0.1-ft"
83
+ _tokenizer = AutoTokenizer.from_pretrained(model_name)
 
 
 
 
 
84
  logger.info(f"[{_ts()}] [TTS] Tokenizer loaded: {time.time()-t1:.2f}s")
85
 
86
  # 2. Load model
 
89
  model_name,
90
  torch_dtype=dtype,
91
  device_map="auto",
 
92
  )
93
  _model.eval()
94
  logger.info(f"[{_ts()}] [TTS] Model loaded: {time.time()-t2:.2f}s")
 
100
  _snac_model = _snac_model.cuda()
101
  logger.info(f"[{_ts()}] [TTS] SNAC decoder loaded: {time.time()-t3:.2f}s")
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  _initialized = True
104
+ logger.info(f"[{_ts()}] [TTS] ✓ Orpheus ready in {time.time()-t0:.2f}s (voice: {VOICE})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
  def _build_prompt_tokens(text):
108
  """
109
+ Build input tokens for Orpheus finetuned model.
110
+ Format: [SOH] {voice}: {text} [EOT EOH]
 
111
  """
112
+ prompt = f"{VOICE}: {text}"
113
+ tokens = _tokenizer(prompt, return_tensors="pt")
114
+ start = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
115
+ end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
116
+ input_ids = torch.cat([start, tokens.input_ids, end], dim=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  return input_ids.to(_model.device)
118
 
119
 
 
171
 
172
  def generate_audio(text: str, output_filename: str = None) -> str:
173
  """
174
+ Generate speech audio from text using local Orpheus finetuned model.
175
  Returns path to wav file, or None on failure.
176
  """
177
  if not text or not text.strip():
 
200
  attention_mask = torch.ones_like(input_ids)
201
  logger.info(f"[{_ts()}] [TTS] Prompt: {input_ids.shape[1]} tokens")
202
 
203
+ # Generate with official Orpheus settings
204
  t1 = time.time()
205
  with torch.no_grad():
206
  generated_ids = _model.generate(
207
  input_ids=input_ids,
208
  attention_mask=attention_mask,
209
+ max_new_tokens=1200,
210
  do_sample=True,
211
  temperature=0.6,
212
+ top_p=0.95,
213
+ repetition_penalty=1.1,
214
  eos_token_id=TOKEN_AUDIO_END,
215
  )
216
  t2 = time.time()