J Z commited on
Commit
faecd51
·
verified ·
1 Parent(s): 3920aa5

Update tts.py

Browse files
Files changed (1) hide show
  1. tts.py +27 -153
tts.py CHANGED
@@ -1,25 +1,24 @@
1
  """
2
  TTS module - Local Orpheus TTS (finetuned) with transformers + SNAC.
3
- Uses finetuned model for better audio quality, with optional voice cloning
4
- from a reference audio file. Falls back to named voice if no reference found.
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 if no reference audio - options: tara, leah, jess, leo, dan, mia, zac, zoe
22
- FALLBACK_VOICE = os.environ.get("ORPHEUS_VOICE", "leo")
 
23
 
24
  # Special token IDs for Orpheus
25
  TOKEN_START_OF_HUMAN = 128259
@@ -36,8 +35,6 @@ AUDIO_CODE_OFFSET = 128266
36
  _model = None
37
  _tokenizer = None
38
  _snac_model = None
39
- _ref_tokens = None # Pre-encoded reference audio tokens
40
- _ref_transcript = "" # Transcript of reference audio
41
  _initialized = False
42
 
43
 
@@ -52,42 +49,40 @@ def ensure_temp_dir():
52
 
53
  def initialize():
54
  """
55
- Load Orpheus finetuned model, tokenizer, SNAC decoder,
56
- and optionally pre-encode reference audio for voice cloning.
57
  Call once at startup.
58
  """
59
- global _model, _tokenizer, _snac_model, _ref_tokens, _ref_transcript, _initialized
60
 
61
  if _initialized:
62
  return
63
 
64
  t0 = time.time()
65
- logger.info(f"[{_ts()}] [TTS] Initializing local Orpheus TTS (finetuned)...")
66
 
67
  from transformers import AutoModelForCausalLM, AutoTokenizer
68
  from snac import SNAC
69
 
70
  # Determine dtype based on GPU capability
71
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
72
  if torch.cuda.is_available():
73
  capability = torch.cuda.get_device_capability()
74
  if capability[0] >= 8:
75
  dtype = torch.bfloat16
76
- logger.info(f"[{_ts()}] [TTS] GPU compute {capability[0]}.{capability[1]} - using bfloat16")
77
  else:
78
  dtype = torch.float16
79
- logger.info(f"[{_ts()}] [TTS] GPU compute {capability[0]}.{capability[1]} - using float16 (T4)")
80
  else:
81
  dtype = torch.float32
82
- logger.info(f"[{_ts()}] [TTS] No GPU - using float32 (will be slow)")
83
 
84
- # 1. Load tokenizer - finetuned model is NOT gated, no auth needed
85
  t1 = time.time()
86
  model_name = "canopylabs/orpheus-3b-0.1-ft"
87
  _tokenizer = AutoTokenizer.from_pretrained(model_name)
88
  logger.info(f"[{_ts()}] [TTS] Tokenizer loaded: {time.time()-t1:.2f}s")
89
 
90
- # 2. Load model
91
  t2 = time.time()
92
  _model = AutoModelForCausalLM.from_pretrained(
93
  model_name,
@@ -102,142 +97,22 @@ def initialize():
102
  _snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval()
103
  if torch.cuda.is_available():
104
  _snac_model = _snac_model.cuda()
105
- logger.info(f"[{_ts()}] [TTS] SNAC decoder loaded: {time.time()-t3:.2f}s")
106
-
107
- # 4. Pre-encode reference audio for voice cloning
108
- _ref_tokens = _load_reference_audio()
109
-
110
- # 5. Load reference transcript if available (improves voice cloning quality)
111
- _ref_transcript = ""
112
- if _ref_tokens is not None:
113
- for d in [VOICE_REF_DIR, "/app"]:
114
- for name in ["voice_transcript.txt", "transcript.txt"]:
115
- path = os.path.join(d, name)
116
- if os.path.exists(path):
117
- _ref_transcript = open(path).read().strip()
118
- logger.info(f"[{_ts()}] [TTS] Reference transcript: \"{_ref_transcript[:60]}...\"")
119
- break
120
- if _ref_transcript:
121
- break
122
- if not _ref_transcript:
123
- logger.info(f"[{_ts()}] [TTS] No transcript file. Add voice_transcript.txt for better cloning.")
124
 
125
  _initialized = True
126
- mode = "voice cloning" if _ref_tokens is not None else f"named voice ({FALLBACK_VOICE})"
127
- logger.info(f"[{_ts()}] [TTS] ✓ Orpheus ready in {time.time()-t0:.2f}s | mode: {mode}")
128
-
129
-
130
- def _load_reference_audio():
131
- """
132
- Find and encode reference audio for voice cloning.
133
- Looks for voice.mp4/voice.wav in assets and app root.
134
- """
135
- import librosa
136
-
137
- ref_path = None
138
- search_dirs = [VOICE_REF_DIR, "/app"]
139
- search_names = ["voice.mp4", "voice.wav", "voice.mp3"]
140
-
141
- for d in search_dirs:
142
- for name in search_names:
143
- candidate = os.path.join(d, name)
144
- if os.path.exists(candidate) and os.path.getsize(candidate) > 1024:
145
- ref_path = candidate
146
- break
147
- if ref_path:
148
- break
149
-
150
- if ref_path is None:
151
- logger.info(f"[{_ts()}] [TTS] No reference audio found. Using named voice: {FALLBACK_VOICE}")
152
- return None
153
-
154
- logger.info(f"[{_ts()}] [TTS] Loading reference audio: {ref_path}")
155
- ensure_temp_dir()
156
-
157
- # Extract audio to wav if not already wav
158
- wav_path = ref_path
159
- if not ref_path.endswith(".wav"):
160
- wav_path = os.path.join(TEMP_DIR, "voice_ref.wav")
161
- try:
162
- subprocess.run([
163
- "ffmpeg", "-y", "-i", ref_path,
164
- "-ar", str(SAMPLE_RATE), "-ac", "1", "-f", "wav", wav_path
165
- ], capture_output=True, check=True)
166
- logger.info(f"[{_ts()}] [TTS] Extracted audio from {os.path.basename(ref_path)}")
167
- except Exception as e:
168
- logger.error(f"[{_ts()}] [TTS] ffmpeg extraction failed: {e}")
169
- return None
170
-
171
- try:
172
- t0 = time.time()
173
- audio_array, sr = librosa.load(wav_path, sr=SAMPLE_RATE)
174
-
175
- # Limit to 15 seconds for reference
176
- max_samples = 15 * SAMPLE_RATE
177
- if len(audio_array) > max_samples:
178
- audio_array = audio_array[:max_samples]
179
- logger.info(f"[{_ts()}] [TTS] Trimmed reference to 15s")
180
-
181
- duration = len(audio_array) / SAMPLE_RATE
182
- logger.info(f"[{_ts()}] [TTS] Reference audio: {duration:.1f}s")
183
-
184
- waveform = torch.from_numpy(audio_array).unsqueeze(0).unsqueeze(0).float()
185
- if torch.cuda.is_available():
186
- waveform = waveform.cuda()
187
-
188
- with torch.inference_mode():
189
- codes = _snac_model.encode(waveform)
190
-
191
- # Interleave SNAC codes into Orpheus token format
192
- all_codes = []
193
- for i in range(codes[0].shape[1]):
194
- all_codes.append(codes[0][0][i].item() + AUDIO_CODE_OFFSET)
195
- all_codes.append(codes[1][0][2 * i].item() + AUDIO_CODE_OFFSET + 4096)
196
- all_codes.append(codes[2][0][4 * i].item() + AUDIO_CODE_OFFSET + (2 * 4096))
197
- all_codes.append(codes[2][0][(4 * i) + 1].item() + AUDIO_CODE_OFFSET + (3 * 4096))
198
- all_codes.append(codes[1][0][(2 * i) + 1].item() + AUDIO_CODE_OFFSET + (4 * 4096))
199
- all_codes.append(codes[2][0][(4 * i) + 2].item() + AUDIO_CODE_OFFSET + (5 * 4096))
200
- all_codes.append(codes[2][0][(4 * i) + 3].item() + AUDIO_CODE_OFFSET + (6 * 4096))
201
-
202
- logger.info(f"[{_ts()}] [TTS] Reference encoded: {len(all_codes)} tokens ({time.time()-t0:.2f}s)")
203
- return all_codes
204
-
205
- except Exception as e:
206
- logger.error(f"[{_ts()}] [TTS] Failed to encode reference: {e}", exc_info=True)
207
- return None
208
 
209
 
210
  def _build_prompt_tokens(text):
211
  """
212
- Build input tokens for Orpheus generation.
213
- With voice cloning: reference audio tokens + text prompt.
214
- Without: named voice format (e.g. "leo: text").
215
  """
216
- if _ref_tokens is not None:
217
- # Voice cloning: [SOH][transcript][EOT EOH SOA AUDIO_START][ref_audio][AUDIO_END EOAI][SOH][gen_text][EOT EOH]
218
- start = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
219
- end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN, TOKEN_START_OF_AI, TOKEN_AUDIO_START]], dtype=torch.int64)
220
- final = torch.tensor([[TOKEN_AUDIO_END, TOKEN_END_OF_AI]], dtype=torch.int64)
221
-
222
- ref_text = _tokenizer(_ref_transcript, return_tensors="pt").input_ids
223
- ref_input = torch.cat([
224
- start, ref_text, end,
225
- torch.tensor([_ref_tokens], dtype=torch.int64),
226
- final
227
- ], dim=1)
228
-
229
- gen_text = _tokenizer(text, return_tensors="pt").input_ids
230
- gen_end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
231
-
232
- input_ids = torch.cat([ref_input, start, gen_text, gen_end], dim=1)
233
- else:
234
- # Named voice format for finetuned model
235
- prompt = f"{FALLBACK_VOICE}: {text}"
236
- tokens = _tokenizer(prompt, return_tensors="pt")
237
- start = torch.tensor([[TOKEN_START_OF_HUMAN]], dtype=torch.int64)
238
- end = torch.tensor([[TOKEN_END_OF_TEXT, TOKEN_END_OF_HUMAN]], dtype=torch.int64)
239
- input_ids = torch.cat([start, tokens.input_ids, end], dim=1)
240
-
241
  return input_ids.to(_model.device)
242
 
243
 
@@ -295,8 +170,7 @@ def _decode_audio_tokens(generated_ids):
295
 
296
  def generate_audio(text: str, output_filename: str = None) -> str:
297
  """
298
- Generate speech audio from text using local Orpheus finetuned model.
299
- Returns path to wav file, or None on failure.
300
  """
301
  if not text or not text.strip():
302
  return None
@@ -324,7 +198,7 @@ def generate_audio(text: str, output_filename: str = None) -> str:
324
  attention_mask = torch.ones_like(input_ids)
325
  logger.info(f"[{_ts()}] [TTS] Prompt: {input_ids.shape[1]} tokens")
326
 
327
- # Generate with official Orpheus settings
328
  t1 = time.time()
329
  with torch.no_grad():
330
  generated_ids = _model.generate(
@@ -333,8 +207,8 @@ def generate_audio(text: str, output_filename: str = None) -> str:
333
  max_new_tokens=1200,
334
  do_sample=True,
335
  temperature=0.6,
336
- top_p=0.95,
337
- repetition_penalty=1.1,
338
  eos_token_id=TOKEN_AUDIO_END,
339
  )
340
  t2 = time.time()
 
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
  _model = None
36
  _tokenizer = None
37
  _snac_model = None
 
 
38
  _initialized = False
39
 
40
 
 
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,
 
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
 
 
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
  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(
 
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()