marcos Claude Opus 4.5 commited on
Commit
32fdbcd
·
1 Parent(s): 512101b

feat: Replace Matcha with Soprano TTS and add inference pipeline

Browse files

- Switch from Matcha TTS to Soprano TTS for simpler setup
- Remove Gemma local model (using Groq API instead)
- Add fallback tokenizer loading in dataset creation
- Make text_tokens optional when tokenizer unavailable
- Add inference.py for end-to-end speech-to-speech
- Add TTS service abstractions (soprano, matcha, piper, gemma)
- Add dataset verification script
- Fix version constraints for WhisperX compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

.gitignore CHANGED
@@ -25,3 +25,4 @@ temp_*/
25
  # IDE
26
  .vscode/
27
  .idea/
 
 
25
  # IDE
26
  .vscode/
27
  .idea/
28
+ datasets/samples/
datasets/verify_dataset.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Verify dataset quality by transcribing audio and comparing with original text.
4
+ Uses WhisperX to transcribe TTS-generated audio.
5
+ """
6
+ import os
7
+ import sys
8
+ import torch
9
+ import numpy as np
10
+ from difflib import SequenceMatcher
11
+
12
+ # Patch torch.load
13
+ _orig = torch.load
14
+ torch.load = lambda *a, **kw: _orig(*a, **{**kw, 'weights_only': False})
15
+
16
+
17
+ def similarity(a: str, b: str) -> float:
18
+ """Calculate similarity ratio between two strings."""
19
+ return SequenceMatcher(None, a.lower(), b.lower()).ratio()
20
+
21
+
22
+ def main():
23
+ import argparse
24
+ parser = argparse.ArgumentParser(description="Verify dataset audio quality")
25
+ parser.add_argument("--dataset", type=str, default="./data/test_b200.pt", help="Dataset path")
26
+ parser.add_argument("--samples", type=int, default=10, help="Number of samples to verify")
27
+ parser.add_argument("--gpu", type=int, default=0, help="GPU to use")
28
+ args = parser.parse_args()
29
+
30
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
31
+
32
+ import torchaudio
33
+ import whisperx
34
+
35
+ print("=" * 60)
36
+ print("Dataset Audio Verification")
37
+ print("=" * 60)
38
+
39
+ # Load dataset
40
+ print(f"\nLoading dataset: {args.dataset}")
41
+ data = torch.load(args.dataset)
42
+ print(f"Total items: {len(data)}")
43
+
44
+ # Load TTS for regenerating audio
45
+ print("\nLoading TTS...")
46
+ from soprano import SopranoTTS
47
+ tts = SopranoTTS(backend="transformers", device="cuda")
48
+
49
+ # Load WhisperX for transcription
50
+ print("Loading WhisperX...")
51
+ wx_model = whisperx.load_model("large-v3-turbo", "cuda", compute_type="float16", language="en")
52
+
53
+ # Select random samples
54
+ import random
55
+ indices = random.sample(range(len(data)), min(args.samples, len(data)))
56
+
57
+ print(f"\nVerifying {len(indices)} samples...\n")
58
+ print("-" * 60)
59
+
60
+ results = {
61
+ "question": {"total": 0, "good": 0, "similarities": []},
62
+ "answer": {"total": 0, "good": 0, "similarities": []}
63
+ }
64
+
65
+ for i, idx in enumerate(indices):
66
+ item = data[idx]
67
+ q_text = item["text"]
68
+ a_text = item["answer"]
69
+
70
+ print(f"[{i+1}/{len(indices)}] Sample {idx}")
71
+
72
+ # Generate audio with TTS
73
+ q_audio = tts.infer(q_text)
74
+ a_audio = tts.infer(a_text)
75
+
76
+ # Convert to numpy
77
+ q_np = q_audio.cpu().numpy() if hasattr(q_audio, 'cpu') else np.array(q_audio)
78
+ a_np = a_audio.cpu().numpy() if hasattr(a_audio, 'cpu') else np.array(a_audio)
79
+
80
+ # Resample to 16kHz for WhisperX
81
+ q_16k = torchaudio.functional.resample(torch.from_numpy(q_np), 32000, 16000).numpy()
82
+ a_16k = torchaudio.functional.resample(torch.from_numpy(a_np), 32000, 16000).numpy()
83
+
84
+ # Transcribe
85
+ q_result = wx_model.transcribe(q_16k, batch_size=1)
86
+ a_result = wx_model.transcribe(a_16k, batch_size=1)
87
+
88
+ q_transcribed = " ".join([s["text"].strip() for s in q_result["segments"]])
89
+ a_transcribed = " ".join([s["text"].strip() for s in a_result["segments"]])
90
+
91
+ # Calculate similarity
92
+ q_sim = similarity(q_text, q_transcribed)
93
+ a_sim = similarity(a_text, a_transcribed)
94
+
95
+ results["question"]["total"] += 1
96
+ results["answer"]["total"] += 1
97
+ results["question"]["similarities"].append(q_sim)
98
+ results["answer"]["similarities"].append(a_sim)
99
+
100
+ if q_sim >= 0.8:
101
+ results["question"]["good"] += 1
102
+ if a_sim >= 0.8:
103
+ results["answer"]["good"] += 1
104
+
105
+ # Print results
106
+ q_status = "✓" if q_sim >= 0.8 else "✗"
107
+ a_status = "✓" if a_sim >= 0.8 else "✗"
108
+
109
+ print(f" Question ({q_sim:.0%}) {q_status}")
110
+ print(f" Original: \"{q_text[:60]}...\"" if len(q_text) > 60 else f" Original: \"{q_text}\"")
111
+ print(f" Transcribed: \"{q_transcribed[:60]}...\"" if len(q_transcribed) > 60 else f" Transcribed: \"{q_transcribed}\"")
112
+
113
+ print(f" Answer ({a_sim:.0%}) {a_status}")
114
+ print(f" Original: \"{a_text[:60]}...\"" if len(a_text) > 60 else f" Original: \"{a_text}\"")
115
+ print(f" Transcribed: \"{a_transcribed[:60]}...\"" if len(a_transcribed) > 60 else f" Transcribed: \"{a_transcribed}\"")
116
+ print()
117
+
118
+ # Summary
119
+ print("=" * 60)
120
+ print("SUMMARY")
121
+ print("=" * 60)
122
+
123
+ q_avg = np.mean(results["question"]["similarities"])
124
+ a_avg = np.mean(results["answer"]["similarities"])
125
+
126
+ print(f"\nQuestions:")
127
+ print(f" Good (>=80%): {results['question']['good']}/{results['question']['total']}")
128
+ print(f" Avg similarity: {q_avg:.1%}")
129
+
130
+ print(f"\nAnswers:")
131
+ print(f" Good (>=80%): {results['answer']['good']}/{results['answer']['total']}")
132
+ print(f" Avg similarity: {a_avg:.1%}")
133
+
134
+ overall = (q_avg + a_avg) / 2
135
+ print(f"\nOverall similarity: {overall:.1%}")
136
+
137
+ if overall >= 0.85:
138
+ print("\n✓ Dataset quality: GOOD")
139
+ elif overall >= 0.70:
140
+ print("\n⚠ Dataset quality: ACCEPTABLE")
141
+ else:
142
+ print("\n✗ Dataset quality: POOR")
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
inference.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Inference script for Speech-to-Speech model.
4
+
5
+ Usage:
6
+ python inference.py --checkpoint ./checkpoints/stage2_best.pt --input audio.wav --output response.wav
7
+ python inference.py --checkpoint ./checkpoints/stage2_best.pt --text "Hello, how are you?"
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import argparse
13
+ import torch
14
+ import torch.nn as nn
15
+ import numpy as np
16
+
17
+ # SNAC token offsets for Orpheus
18
+ SNAC_BASE_OFFSET = 128266
19
+ EOS_TOKEN = 128009
20
+
21
+
22
+ class SpeechAdapter(nn.Module):
23
+ """Same architecture as training - must match exactly."""
24
+ def __init__(self, whisper_dim=1280, llm_dim=3072, downsample=5, intermediate_dim=2048):
25
+ super().__init__()
26
+ self.downsample = downsample
27
+ concat_dim = whisper_dim * downsample
28
+
29
+ self.ffn = nn.Sequential(
30
+ nn.Linear(concat_dim, intermediate_dim),
31
+ nn.GELU(),
32
+ nn.Linear(intermediate_dim, llm_dim),
33
+ nn.LayerNorm(llm_dim)
34
+ )
35
+
36
+ def forward(self, x):
37
+ B, T, D = x.shape
38
+ T_new = (T // self.downsample) * self.downsample
39
+ x = x[:, :T_new]
40
+ x = x.reshape(B, T_new // self.downsample, D * self.downsample)
41
+ return self.ffn(x)
42
+
43
+
44
+ def decode_snac_tokens(snac_tokens, device="cuda"):
45
+ """Decode SNAC tokens to audio waveform."""
46
+ try:
47
+ from snac import SNAC
48
+
49
+ # Load SNAC model
50
+ snac = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device)
51
+ snac.eval()
52
+
53
+ # Remove offsets from tokens
54
+ raw_tokens = []
55
+ for i, tok in enumerate(snac_tokens):
56
+ pos = i % 7
57
+ offset = SNAC_BASE_OFFSET + pos * 4096
58
+ raw_tok = tok - offset
59
+ if 0 <= raw_tok < 4096:
60
+ raw_tokens.append(raw_tok)
61
+
62
+ if len(raw_tokens) == 0:
63
+ return None, 24000
64
+
65
+ # Reshape to SNAC format: 7 tokens per frame
66
+ num_frames = len(raw_tokens) // 7
67
+ if num_frames == 0:
68
+ return None, 24000
69
+
70
+ raw_tokens = raw_tokens[:num_frames * 7]
71
+
72
+ # SNAC expects [batch, layers, time] - 3 layers with different rates
73
+ # Layer 0: 1 token/frame, Layer 1: 2 tokens/frame, Layer 2: 4 tokens/frame
74
+ codes = []
75
+ for frame_idx in range(num_frames):
76
+ base = frame_idx * 7
77
+ codes.append(raw_tokens[base:base+7])
78
+
79
+ codes = torch.tensor(codes, device=device)
80
+
81
+ # Reorganize into SNAC layer format
82
+ layer0 = codes[:, 0:1].T # [1, num_frames]
83
+ layer1 = codes[:, 1:3].T.reshape(1, -1) # [1, num_frames*2]
84
+ layer2 = codes[:, 3:7].T.reshape(1, -1) # [1, num_frames*4]
85
+
86
+ with torch.no_grad():
87
+ audio = snac.decode([layer0, layer1, layer2])
88
+
89
+ return audio.cpu().numpy().squeeze(), 24000
90
+
91
+ except Exception as e:
92
+ print(f"SNAC decode error: {e}")
93
+ return None, 24000
94
+
95
+
96
+ def extract_whisper_features(audio_path, device="cuda"):
97
+ """Extract Whisper encoder features from audio file."""
98
+ try:
99
+ from transformers import WhisperProcessor, WhisperModel
100
+ import librosa
101
+
102
+ # Load audio
103
+ audio, sr = librosa.load(audio_path, sr=16000)
104
+
105
+ # Load Whisper
106
+ processor = WhisperProcessor.from_pretrained("openai/whisper-large-v3")
107
+ model = WhisperModel.from_pretrained("openai/whisper-large-v3").to(device)
108
+ model.eval()
109
+
110
+ # Process
111
+ inputs = processor(audio, sampling_rate=16000, return_tensors="pt")
112
+ input_features = inputs.input_features.to(device)
113
+
114
+ with torch.no_grad():
115
+ encoder_outputs = model.encoder(input_features)
116
+ features = encoder_outputs.last_hidden_state
117
+
118
+ return features
119
+
120
+ except Exception as e:
121
+ print(f"Whisper feature extraction error: {e}")
122
+ return None
123
+
124
+
125
+ def generate_response(model, adapter, tokenizer, audio_embeds, device, max_new_tokens=500):
126
+ """Generate interleaved text+audio response."""
127
+
128
+ # Get the base model for generation
129
+ if hasattr(model, 'get_base_model'):
130
+ base_model = model.get_base_model()
131
+ else:
132
+ base_model = model
133
+
134
+ # Start generation from audio embeddings
135
+ generated_tokens = []
136
+
137
+ # Create initial input from audio embeddings
138
+ current_embeds = audio_embeds
139
+
140
+ with torch.no_grad():
141
+ for step in range(max_new_tokens):
142
+ # Forward pass
143
+ outputs = model(inputs_embeds=current_embeds, use_cache=False)
144
+ logits = outputs.logits
145
+
146
+ # Get next token (greedy)
147
+ next_token_logits = logits[:, -1, :]
148
+ next_token = torch.argmax(next_token_logits, dim=-1)
149
+
150
+ token_id = next_token.item()
151
+ generated_tokens.append(token_id)
152
+
153
+ # Check for EOS
154
+ if token_id == EOS_TOKEN:
155
+ break
156
+
157
+ # Get embedding for next token
158
+ if hasattr(base_model, 'model'):
159
+ next_embed = base_model.model.embed_tokens(next_token.unsqueeze(0))
160
+ else:
161
+ next_embed = base_model.embed_tokens(next_token.unsqueeze(0))
162
+
163
+ # Append to current embeddings
164
+ current_embeds = torch.cat([current_embeds, next_embed], dim=1)
165
+
166
+ # Truncate if too long (keep last 2048 tokens)
167
+ if current_embeds.shape[1] > 2048:
168
+ current_embeds = current_embeds[:, -2048:]
169
+
170
+ return generated_tokens
171
+
172
+
173
+ def separate_tokens(generated_tokens):
174
+ """Separate text and audio tokens from interleaved output."""
175
+ text_tokens = []
176
+ audio_tokens = []
177
+
178
+ for tok in generated_tokens:
179
+ if tok >= SNAC_BASE_OFFSET:
180
+ audio_tokens.append(tok)
181
+ elif tok != EOS_TOKEN:
182
+ text_tokens.append(tok)
183
+
184
+ return text_tokens, audio_tokens
185
+
186
+
187
+ def main():
188
+ parser = argparse.ArgumentParser(description="Speech-to-Speech Inference")
189
+ parser.add_argument("--checkpoint", type=str, required=True, help="Path to checkpoint (stage1 or stage2)")
190
+ parser.add_argument("--input", type=str, default=None, help="Input audio file")
191
+ parser.add_argument("--text", type=str, default=None, help="Input text (for testing without audio)")
192
+ parser.add_argument("--output", type=str, default="./output.wav", help="Output audio file")
193
+ parser.add_argument("--model_path", type=str, default="canopylabs/3b-es_it-ft-research_release")
194
+ parser.add_argument("--max_tokens", type=int, default=500)
195
+ parser.add_argument("--device", type=str, default=None)
196
+ args = parser.parse_args()
197
+
198
+ # Determine device
199
+ if args.device:
200
+ device = torch.device(args.device)
201
+ elif torch.cuda.is_available():
202
+ device = torch.device("cuda")
203
+ elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
204
+ device = torch.device("mps")
205
+ else:
206
+ device = torch.device("cpu")
207
+
208
+ print(f"Device: {device}")
209
+
210
+ # Determine dtype
211
+ torch_dtype = torch.bfloat16 if device.type == 'cuda' else torch.float32
212
+
213
+ # Load tokenizer
214
+ print(f"Loading tokenizer: {args.model_path}")
215
+ from transformers import AutoTokenizer, AutoModelForCausalLM
216
+ tokenizer = AutoTokenizer.from_pretrained(args.model_path)
217
+
218
+ # Load checkpoint
219
+ print(f"Loading checkpoint: {args.checkpoint}")
220
+ ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
221
+
222
+ has_lora = "lora" in ckpt
223
+ print(f"Checkpoint type: {'Stage 2 (Adapter + LoRA)' if has_lora else 'Stage 1 (Adapter only)'}")
224
+
225
+ # Load LLM
226
+ print(f"Loading LLM: {args.model_path}")
227
+ llm = AutoModelForCausalLM.from_pretrained(
228
+ args.model_path,
229
+ torch_dtype=torch_dtype,
230
+ attn_implementation="sdpa",
231
+ ).to(device)
232
+
233
+ # Apply LoRA if Stage 2
234
+ if has_lora:
235
+ from peft import LoraConfig, get_peft_model, TaskType
236
+ lora_config = LoraConfig(
237
+ r=16,
238
+ lora_alpha=32,
239
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
240
+ lora_dropout=0.0,
241
+ bias="none",
242
+ task_type=TaskType.CAUSAL_LM
243
+ )
244
+ llm = get_peft_model(llm, lora_config)
245
+ llm.load_state_dict(ckpt["lora"], strict=False)
246
+ print("LoRA weights loaded")
247
+
248
+ llm.eval()
249
+
250
+ # Load adapter
251
+ print("Loading adapter...")
252
+ adapter = SpeechAdapter(
253
+ whisper_dim=1280,
254
+ llm_dim=3072,
255
+ downsample=5,
256
+ intermediate_dim=2048
257
+ ).to(device, dtype=torch_dtype)
258
+ adapter.load_state_dict(ckpt["adapter"])
259
+ adapter.eval()
260
+ print("Adapter loaded")
261
+
262
+ # Get input embeddings
263
+ if args.input:
264
+ print(f"Processing audio: {args.input}")
265
+ whisper_features = extract_whisper_features(args.input, device)
266
+ if whisper_features is None:
267
+ print("Failed to extract Whisper features")
268
+ return
269
+ audio_embeds = adapter(whisper_features.to(torch_dtype))
270
+
271
+ elif args.text:
272
+ print(f"Processing text: {args.text}")
273
+ # For text input, create dummy audio embeddings (zeros)
274
+ # This is just for testing the generation pipeline
275
+ dummy_features = torch.randn(1, 100, 1280, device=device, dtype=torch_dtype)
276
+ audio_embeds = adapter(dummy_features)
277
+
278
+ # Optionally prepend text tokens
279
+ text_tokens = tokenizer.encode(args.text, add_special_tokens=False)
280
+ print(f"Text tokens: {text_tokens[:10]}...")
281
+ else:
282
+ print("ERROR: Provide --input (audio file) or --text")
283
+ return
284
+
285
+ print(f"Audio embeddings shape: {audio_embeds.shape}")
286
+
287
+ # Generate response
288
+ print(f"Generating response (max {args.max_tokens} tokens)...")
289
+ generated_tokens = generate_response(
290
+ llm, adapter, tokenizer, audio_embeds, device,
291
+ max_new_tokens=args.max_tokens
292
+ )
293
+
294
+ print(f"Generated {len(generated_tokens)} tokens")
295
+
296
+ # Separate text and audio
297
+ text_tokens, audio_tokens = separate_tokens(generated_tokens)
298
+ print(f"Text tokens: {len(text_tokens)}, Audio tokens: {len(audio_tokens)}")
299
+
300
+ # Decode text
301
+ if text_tokens:
302
+ decoded_text = tokenizer.decode(text_tokens, skip_special_tokens=True)
303
+ print(f"\nGenerated text: {decoded_text}")
304
+
305
+ # Decode audio
306
+ if audio_tokens:
307
+ print(f"\nDecoding {len(audio_tokens)} audio tokens...")
308
+ audio, sr = decode_snac_tokens(audio_tokens, device)
309
+
310
+ if audio is not None:
311
+ import soundfile as sf
312
+ sf.write(args.output, audio, sr)
313
+ print(f"Audio saved: {args.output}")
314
+ else:
315
+ print("Failed to decode audio")
316
+ else:
317
+ print("No audio tokens generated")
318
+
319
+ # Show raw tokens for debugging
320
+ print(f"\nFirst 20 generated tokens: {generated_tokens[:20]}")
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
services/gemma_service.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gemma 3 1B Service for Q&A generation using llama.cpp.
3
+
4
+ Requires: pip install llama-cpp-python
5
+ For GPU: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python --force-reinstall --no-cache-dir
6
+ """
7
+ import os
8
+ from typing import List, Dict, Optional
9
+
10
+ # Default model path - Google's official QAT Q4_0 version (smallest, ~600MB)
11
+ DEFAULT_MODEL_PATH = "/tmp/gemma-3-1b-it-q4_0.gguf"
12
+ DEFAULT_MODEL_URL = "https://huggingface.co/google/gemma-3-1b-it-qat-q4_0-gguf/resolve/main/gemma-3-1b-it-q4_0.gguf"
13
+
14
+
15
+ class GemmaService:
16
+ """Service for generating Q&A pairs using Gemma 3 1B via llama.cpp."""
17
+
18
+ def __init__(
19
+ self,
20
+ model_path: str = DEFAULT_MODEL_PATH,
21
+ n_gpu_layers: int = -1, # -1 = all layers on GPU
22
+ n_ctx: int = 4096,
23
+ n_batch: int = 512,
24
+ verbose: bool = False
25
+ ):
26
+ self.model_path = model_path
27
+ self.n_gpu_layers = n_gpu_layers
28
+ self.n_ctx = n_ctx
29
+ self.n_batch = n_batch
30
+ self.verbose = verbose
31
+ self._llm = None
32
+
33
+ def download_model(self) -> bool:
34
+ """Download the model if not present."""
35
+ if os.path.exists(self.model_path):
36
+ print(f"[GemmaService] Model already exists: {self.model_path}")
37
+ return True
38
+
39
+ print(f"[GemmaService] Downloading Gemma 3 1B to {self.model_path}...")
40
+ import subprocess
41
+ result = subprocess.run(
42
+ ["wget", "-q", "--show-progress", "-O", self.model_path, DEFAULT_MODEL_URL],
43
+ capture_output=False
44
+ )
45
+ return result.returncode == 0
46
+
47
+ def load(self) -> 'GemmaService':
48
+ """Load the Gemma model."""
49
+ from llama_cpp import Llama
50
+
51
+ if not os.path.exists(self.model_path):
52
+ self.download_model()
53
+
54
+ print(f"[GemmaService] Loading Gemma 3 1B from {self.model_path}...")
55
+ print(f"[GemmaService] GPU layers: {self.n_gpu_layers}, Context: {self.n_ctx}")
56
+
57
+ self._llm = Llama(
58
+ model_path=self.model_path,
59
+ n_gpu_layers=self.n_gpu_layers,
60
+ n_ctx=self.n_ctx,
61
+ n_batch=self.n_batch,
62
+ verbose=self.verbose
63
+ )
64
+
65
+ print(f"[GemmaService] Model loaded successfully")
66
+ return self
67
+
68
+ def generate(
69
+ self,
70
+ prompt: str,
71
+ max_tokens: int = 512,
72
+ temperature: float = 0.7,
73
+ stop: Optional[List[str]] = None
74
+ ) -> str:
75
+ """Generate text from prompt."""
76
+ if self._llm is None:
77
+ self.load()
78
+
79
+ response = self._llm(
80
+ prompt,
81
+ max_tokens=max_tokens,
82
+ temperature=temperature,
83
+ stop=stop or [],
84
+ echo=False
85
+ )
86
+
87
+ return response['choices'][0]['text'].strip()
88
+
89
+ def generate_qa_pairs(self, count: int) -> List[Dict[str, str]]:
90
+ """Generate Q&A pairs using Gemma 3 chat template."""
91
+ if self._llm is None:
92
+ self.load()
93
+
94
+ # Gemma 3 chat template
95
+ prompt = f"""<start_of_turn>user
96
+ Generate {count} unique question-answer pairs about general knowledge.
97
+
98
+ REQUIREMENTS:
99
+ - Questions: 5 to 10 words
100
+ - Answers: 5 to 10 words (SHORT answers only!)
101
+ - Topics: science, history, geography, arts, sports
102
+
103
+ Format each pair EXACTLY as:
104
+ Q: [question]
105
+ A: [answer]
106
+
107
+ Generate exactly {count} pairs now:<end_of_turn>
108
+ <start_of_turn>model
109
+ """
110
+
111
+ response = self._llm(
112
+ prompt,
113
+ max_tokens=count * 60, # ~60 tokens per QA pair
114
+ temperature=0.8,
115
+ stop=["<end_of_turn>", "<start_of_turn>"],
116
+ echo=False
117
+ )
118
+
119
+ content = response['choices'][0]['text']
120
+ return self._parse_qa_pairs(content)
121
+
122
+ def _parse_qa_pairs(self, content: str) -> List[Dict[str, str]]:
123
+ """Parse Q&A pairs from generated text."""
124
+ pairs = []
125
+ lines = content.split('\n')
126
+ current_q, current_a = None, None
127
+
128
+ for line in lines:
129
+ line = line.strip()
130
+ if line.lower().startswith('q:'):
131
+ current_q = line[2:].strip()
132
+ elif line.lower().startswith('a:'):
133
+ current_a = line[2:].strip()
134
+ if current_q and current_a:
135
+ word_count_q = len(current_q.split())
136
+ word_count_a = len(current_a.split())
137
+ if 3 <= word_count_q <= 15 and 3 <= word_count_a <= 15:
138
+ pairs.append({'question': current_q, 'answer': current_a})
139
+ current_q, current_a = None, None
140
+
141
+ return pairs
142
+
143
+ def generate_batch(self, total_count: int, batch_size: int = 20) -> List[Dict[str, str]]:
144
+ """Generate multiple batches of Q&A pairs."""
145
+ all_pairs = []
146
+ remaining = total_count
147
+
148
+ while remaining > 0:
149
+ count = min(batch_size, remaining)
150
+ pairs = self.generate_qa_pairs(count)
151
+ all_pairs.extend(pairs)
152
+ remaining -= len(pairs)
153
+
154
+ if not pairs:
155
+ pairs = self.generate_qa_pairs(count)
156
+ all_pairs.extend(pairs)
157
+ remaining -= len(pairs)
158
+ if not pairs:
159
+ break
160
+
161
+ return all_pairs[:total_count]
162
+
163
+ @staticmethod
164
+ def install_with_cuda():
165
+ """Install llama-cpp-python with CUDA support."""
166
+ import subprocess
167
+ print("[GemmaService] Installing llama-cpp-python with CUDA...")
168
+ cmd = 'CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python --force-reinstall --no-cache-dir'
169
+ return subprocess.run(cmd, shell=True).returncode == 0
170
+
171
+
172
+ def test_gemma_service():
173
+ """Test the Gemma service."""
174
+ service = GemmaService()
175
+ service.load()
176
+
177
+ print("\nGenerating 5 Q&A pairs with Gemma 3 1B...")
178
+ pairs = service.generate_qa_pairs(5)
179
+
180
+ for i, pair in enumerate(pairs, 1):
181
+ print(f"\n{i}. Q: {pair['question']}")
182
+ print(f" A: {pair['answer']}")
183
+
184
+ return pairs
185
+
186
+
187
+ if __name__ == "__main__":
188
+ test_gemma_service()
services/matcha_service.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Matcha TTS Service for fast audio generation.
3
+
4
+ Matcha-TTS uses conditional flow matching for fast synthesis.
5
+ Paper: https://arxiv.org/abs/2309.03199 (ICASSP 2024)
6
+
7
+ Requires:
8
+ - pip install matcha-tts OR git clone + pip install -e .
9
+ - apt-get install espeak-ng (for phonemization)
10
+
11
+ For GPU: PyTorch with CUDA
12
+
13
+ Performance on RTX 5060 Ti: ~19 calls/s, 85x real-time
14
+ """
15
+ import os
16
+ from typing import Optional, Tuple
17
+ import numpy as np
18
+ import torch
19
+
20
+ # Fix PyTorch 2.6+ weights_only security for Matcha checkpoints
21
+ # Monkey-patch torch.load to force weights_only=False (needed for PyTorch Lightning checkpoints)
22
+ _original_torch_load = torch.load
23
+ def _patched_torch_load(*args, **kwargs):
24
+ kwargs['weights_only'] = False
25
+ return _original_torch_load(*args, **kwargs)
26
+ torch.load = _patched_torch_load
27
+
28
+
29
+ class MatchaService:
30
+ """Service for TTS using Matcha-TTS (faster than Piper/diffusion-based TTS)."""
31
+
32
+ def __init__(
33
+ self,
34
+ use_cuda: bool = True,
35
+ gpu_id: int = 0,
36
+ n_timesteps: int = 5,
37
+ temperature: float = 0.667,
38
+ length_scale: float = 1.0
39
+ ):
40
+ self.use_cuda = use_cuda
41
+ self.gpu_id = gpu_id
42
+ self.n_timesteps = n_timesteps
43
+ self.temperature = temperature
44
+ self.length_scale = length_scale
45
+
46
+ self._model = None
47
+ self._vocoder = None
48
+ self._denoiser = None
49
+ self._device = None
50
+ self.sample_rate = 22050 # Matcha-TTS default
51
+
52
+ def load(self) -> 'MatchaService':
53
+ """Load the Matcha-TTS model and vocoder."""
54
+ from matcha.cli import (
55
+ load_matcha,
56
+ load_vocoder,
57
+ MATCHA_URLS,
58
+ VOCODER_URLS,
59
+ )
60
+ from matcha.utils.utils import get_user_data_dir
61
+
62
+ # Set device
63
+ if self.use_cuda and torch.cuda.is_available():
64
+ self._device = torch.device(f"cuda:{self.gpu_id}")
65
+ else:
66
+ self._device = torch.device("cpu")
67
+
68
+ print(f"[MatchaService] Loading on {self._device}...")
69
+
70
+ # Get default model paths (auto-download if needed)
71
+ data_dir = get_user_data_dir()
72
+ os.makedirs(data_dir, exist_ok=True)
73
+
74
+ # Load Matcha model (downloads automatically)
75
+ matcha_path = os.path.join(data_dir, "matcha_ljspeech.ckpt")
76
+ if not os.path.exists(matcha_path):
77
+ print(f"[MatchaService] Downloading Matcha model...")
78
+ import urllib.request
79
+ urllib.request.urlretrieve(MATCHA_URLS["matcha_ljspeech"], matcha_path)
80
+
81
+ # load_matcha(model_name, checkpoint_path, device)
82
+ self._model = load_matcha("matcha_ljspeech", matcha_path, self._device)
83
+
84
+ # Load HiFi-GAN vocoder (downloads automatically - it's a direct file, not a zip)
85
+ vocoder_path = os.path.join(data_dir, "hifigan_T2_v1")
86
+ if not os.path.exists(vocoder_path):
87
+ print(f"[MatchaService] Downloading vocoder...")
88
+ import urllib.request
89
+ urllib.request.urlretrieve(VOCODER_URLS["hifigan_T2_v1"], vocoder_path)
90
+
91
+ # load_vocoder(vocoder_name, checkpoint_path, device)
92
+ self._vocoder, self._denoiser = load_vocoder("hifigan_T2_v1", vocoder_path, self._device)
93
+
94
+ print(f"[MatchaService] Loaded successfully (sample_rate={self.sample_rate})")
95
+ return self
96
+
97
+ @torch.inference_mode()
98
+ def synthesize(self, text: str) -> Tuple[np.ndarray, int]:
99
+ """Synthesize text to audio.
100
+
101
+ Returns:
102
+ Tuple of (samples as float32 numpy array, sample_rate)
103
+ """
104
+ if self._model is None:
105
+ self.load()
106
+
107
+ from matcha.cli import process_text, to_waveform
108
+
109
+ # Process text to phonemes
110
+ # process_text(i, text, device) - i is just an index for logging
111
+ text_processed = process_text(0, text, self._device)
112
+ x = text_processed["x"]
113
+ x_lengths = text_processed["x_lengths"]
114
+
115
+ # Synthesize mel spectrogram
116
+ output = self._model.synthesise(
117
+ x,
118
+ x_lengths,
119
+ n_timesteps=self.n_timesteps,
120
+ temperature=self.temperature,
121
+ spks=None,
122
+ length_scale=self.length_scale
123
+ )
124
+
125
+ # Convert mel to waveform
126
+ waveform = to_waveform(
127
+ output["mel"],
128
+ self._vocoder,
129
+ self._denoiser,
130
+ denoiser_strength=0.00025
131
+ )
132
+
133
+ # Convert to numpy float32
134
+ samples = waveform.cpu().numpy().squeeze().astype(np.float32)
135
+
136
+ return samples, self.sample_rate
137
+
138
+ def synthesize_to_file(self, text: str, output_path: str) -> float:
139
+ """Synthesize text and save to WAV file.
140
+
141
+ Returns:
142
+ Duration in seconds
143
+ """
144
+ import wave
145
+
146
+ samples, sr = self.synthesize(text)
147
+
148
+ # Convert to int16 for WAV
149
+ audio_int16 = (samples * 32768).clip(-32768, 32767).astype(np.int16)
150
+
151
+ with wave.open(output_path, 'wb') as wav:
152
+ wav.setnchannels(1)
153
+ wav.setsampwidth(2)
154
+ wav.setframerate(sr)
155
+ wav.writeframes(audio_int16.tobytes())
156
+
157
+ return len(samples) / sr
158
+
159
+
160
+ def test_matcha_service():
161
+ """Test the Matcha TTS service."""
162
+ import time
163
+
164
+ service = MatchaService(use_cuda=True, gpu_id=0)
165
+ service.load()
166
+
167
+ text = "Hello, this is a test of the Matcha text to speech system."
168
+
169
+ # Warmup
170
+ print("[Test] Warmup...")
171
+ for _ in range(3):
172
+ service.synthesize(text)
173
+
174
+ # Benchmark
175
+ print("[Test] Benchmarking...")
176
+ start = time.time()
177
+ n_iterations = 20
178
+ total_duration = 0
179
+ for _ in range(n_iterations):
180
+ samples, sr = service.synthesize(text)
181
+ total_duration += len(samples) / sr
182
+ elapsed = time.time() - start
183
+
184
+ print(f"{n_iterations} synthesize calls in {elapsed:.2f}s ({n_iterations/elapsed:.1f}/s)")
185
+ print(f"Average audio duration: {total_duration/n_iterations:.2f}s")
186
+ print(f"Real-time factor: {total_duration/elapsed:.2f}x")
187
+
188
+ # Save a test file
189
+ service.synthesize_to_file(text, "/tmp/matcha_test.wav")
190
+ print(f"Saved test audio to /tmp/matcha_test.wav")
191
+
192
+ return service
193
+
194
+
195
+ if __name__ == "__main__":
196
+ test_matcha_service()
services/piper_service.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Piper TTS Service for fast audio generation.
3
+
4
+ Requires: pip install piper-tts
5
+ For GPU: needs onnxruntime-gpu with CUDA
6
+ """
7
+ import os
8
+ from typing import Optional, Tuple
9
+ import numpy as np
10
+
11
+
12
+ class PiperService:
13
+ """Service for TTS using Piper (faster than Kokoro)."""
14
+
15
+ DEFAULT_MODEL_PATH = "/tmp/piper/en_US-amy-medium.onnx"
16
+ DEFAULT_MODEL_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx"
17
+ DEFAULT_JSON_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx.json"
18
+
19
+ def __init__(
20
+ self,
21
+ model_path: str = DEFAULT_MODEL_PATH,
22
+ use_cuda: bool = True,
23
+ gpu_id: int = 0
24
+ ):
25
+ self.model_path = model_path
26
+ self.use_cuda = use_cuda
27
+ self.gpu_id = gpu_id
28
+ self._voice = None
29
+ self.sample_rate = 22050
30
+
31
+ def download_model(self) -> bool:
32
+ """Download the model if not present."""
33
+ import subprocess
34
+
35
+ model_dir = os.path.dirname(self.model_path)
36
+ os.makedirs(model_dir, exist_ok=True)
37
+
38
+ if not os.path.exists(self.model_path):
39
+ print(f"[PiperService] Downloading model to {self.model_path}...")
40
+ subprocess.run(["wget", "-q", "-O", self.model_path, self.DEFAULT_MODEL_URL])
41
+
42
+ json_path = self.model_path + ".json"
43
+ if not os.path.exists(json_path):
44
+ print(f"[PiperService] Downloading config...")
45
+ subprocess.run(["wget", "-q", "-O", json_path, self.DEFAULT_JSON_URL])
46
+
47
+ return os.path.exists(self.model_path)
48
+
49
+ def load(self) -> 'PiperService':
50
+ """Load the Piper model."""
51
+ # Set GPU before importing
52
+ if self.use_cuda:
53
+ os.environ['CUDA_VISIBLE_DEVICES'] = str(self.gpu_id)
54
+
55
+ from piper import PiperVoice
56
+
57
+ if not os.path.exists(self.model_path):
58
+ self.download_model()
59
+
60
+ print(f"[PiperService] Loading on GPU {self.gpu_id}...")
61
+ self._voice = PiperVoice.load(self.model_path, use_cuda=self.use_cuda)
62
+ self.sample_rate = self._voice.config.sample_rate
63
+ print(f"[PiperService] Loaded (sample_rate={self.sample_rate})")
64
+
65
+ return self
66
+
67
+ def synthesize(self, text: str) -> Tuple[np.ndarray, int]:
68
+ """Synthesize text to audio.
69
+
70
+ Returns:
71
+ Tuple of (samples as float32 numpy array, sample_rate)
72
+ """
73
+ if self._voice is None:
74
+ self.load()
75
+
76
+ audio_bytes = b''
77
+ for chunk in self._voice.synthesize(text):
78
+ audio_bytes += chunk.audio_int16_bytes
79
+
80
+ # Convert to float32 numpy array (same format as Kokoro)
81
+ samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
82
+
83
+ return samples, self.sample_rate
84
+
85
+ def synthesize_to_file(self, text: str, output_path: str) -> float:
86
+ """Synthesize text and save to WAV file.
87
+
88
+ Returns:
89
+ Duration in seconds
90
+ """
91
+ import wave
92
+
93
+ samples, sr = self.synthesize(text)
94
+
95
+ # Convert back to int16 for WAV
96
+ audio_int16 = (samples * 32768).astype(np.int16)
97
+
98
+ with wave.open(output_path, 'wb') as wav:
99
+ wav.setnchannels(1)
100
+ wav.setsampwidth(2)
101
+ wav.setframerate(sr)
102
+ wav.writeframes(audio_int16.tobytes())
103
+
104
+ return len(samples) / sr
105
+
106
+
107
+ def test_piper_service():
108
+ """Test the Piper service."""
109
+ import time
110
+
111
+ service = PiperService(use_cuda=True, gpu_id=0)
112
+ service.load()
113
+
114
+ text = "Hello, this is a test of the Piper text to speech system."
115
+
116
+ # Warmup
117
+ service.synthesize(text)
118
+
119
+ # Benchmark
120
+ start = time.time()
121
+ for _ in range(10):
122
+ samples, sr = service.synthesize(text)
123
+ elapsed = time.time() - start
124
+
125
+ print(f"10 synthesize calls in {elapsed:.2f}s ({10/elapsed:.1f}/s)")
126
+ print(f"Audio duration: {len(samples)/sr:.2f}s")
127
+
128
+ return service
129
+
130
+
131
+ if __name__ == "__main__":
132
+ test_piper_service()
services/soprano_service.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Soprano TTS Service for ultra-fast audio generation.
3
+
4
+ Soprano is an ultra-lightweight TTS model (80M params) with batch support.
5
+ GitHub: https://github.com/ekwek1/soprano
6
+
7
+ Performance on RTX 5060 Ti: ~121 items/s (8ms/item) with batch=100
8
+
9
+ Requires:
10
+ - pip install soprano-tts
11
+ - CUDA-enabled GPU
12
+ """
13
+ from typing import Optional, Tuple, List
14
+ import numpy as np
15
+
16
+
17
+ class SopranoService:
18
+ """Service for TTS using Soprano (fastest open-source TTS)."""
19
+
20
+ def __init__(
21
+ self,
22
+ use_cuda: bool = True,
23
+ gpu_id: int = 0,
24
+ backend: str = "transformers", # "lmdeploy" or "transformers"
25
+ temperature: float = 0.3,
26
+ top_p: float = 0.95,
27
+ ):
28
+ self.use_cuda = use_cuda
29
+ self.gpu_id = gpu_id
30
+ self.backend = backend
31
+ self.temperature = temperature
32
+ self.top_p = top_p
33
+
34
+ self._model = None
35
+ self.sample_rate = 32000 # Soprano outputs 32kHz
36
+
37
+ def load(self) -> 'SopranoService':
38
+ """Load the Soprano TTS model."""
39
+ import os
40
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(self.gpu_id)
41
+
42
+ from soprano import SopranoTTS
43
+
44
+ device = "cuda" if self.use_cuda else "cpu"
45
+ print(f"[SopranoService] Loading on {device} (GPU {self.gpu_id})...")
46
+
47
+ self._model = SopranoTTS(backend=self.backend, device=device)
48
+
49
+ print(f"[SopranoService] Loaded successfully (sample_rate={self.sample_rate})")
50
+ return self
51
+
52
+ def synthesize(self, text: str) -> Tuple[np.ndarray, int]:
53
+ """Synthesize text to audio.
54
+
55
+ Returns:
56
+ Tuple of (samples as float32 numpy array, sample_rate)
57
+ """
58
+ if self._model is None:
59
+ self.load()
60
+
61
+ audio = self._model.infer(
62
+ text,
63
+ temperature=self.temperature,
64
+ top_p=self.top_p,
65
+ )
66
+
67
+ # Ensure float32 numpy array
68
+ if hasattr(audio, 'numpy'):
69
+ audio = audio.numpy()
70
+ samples = np.asarray(audio, dtype=np.float32)
71
+
72
+ return samples, self.sample_rate
73
+
74
+ def synthesize_batch(self, texts: List[str]) -> List[Tuple[np.ndarray, int]]:
75
+ """Synthesize multiple texts in batch (much faster).
76
+
77
+ Returns:
78
+ List of (samples, sample_rate) tuples
79
+ """
80
+ if self._model is None:
81
+ self.load()
82
+
83
+ audios = self._model.infer_batch(texts)
84
+
85
+ results = []
86
+ for audio in audios:
87
+ if hasattr(audio, 'numpy'):
88
+ audio = audio.numpy()
89
+ samples = np.asarray(audio, dtype=np.float32)
90
+ results.append((samples, self.sample_rate))
91
+
92
+ return results
93
+
94
+ def synthesize_to_file(self, text: str, output_path: str) -> float:
95
+ """Synthesize text and save to WAV file.
96
+
97
+ Returns:
98
+ Duration in seconds
99
+ """
100
+ import wave
101
+
102
+ samples, sr = self.synthesize(text)
103
+
104
+ # Convert to int16 for WAV
105
+ audio_int16 = (samples * 32768).clip(-32768, 32767).astype(np.int16)
106
+
107
+ with wave.open(output_path, 'wb') as wav:
108
+ wav.setnchannels(1)
109
+ wav.setsampwidth(2)
110
+ wav.setframerate(sr)
111
+ wav.writeframes(audio_int16.tobytes())
112
+
113
+ return len(samples) / sr
114
+
115
+
116
+ def test_soprano_service():
117
+ """Test the Soprano TTS service."""
118
+ import time
119
+
120
+ service = SopranoService(use_cuda=True, gpu_id=0)
121
+ service.load()
122
+
123
+ # Test single
124
+ print("[Test] Single inference...")
125
+ text = "Hello, this is a test of the Soprano text to speech system."
126
+ samples, sr = service.synthesize(text)
127
+ print(f"Single: {len(samples)} samples, {len(samples)/sr:.2f}s")
128
+
129
+ # Test batch
130
+ print("[Test] Batch inference...")
131
+ texts = [
132
+ "What is the capital of France?",
133
+ "Paris is the capital of France.",
134
+ "How does rain form?",
135
+ "Water evaporates and condenses.",
136
+ ] * 25 # 100 items
137
+
138
+ start = time.time()
139
+ results = service.synthesize_batch(texts)
140
+ elapsed = time.time() - start
141
+
142
+ print(f"Batch of {len(texts)}: {elapsed:.2f}s ({len(texts)/elapsed:.1f}/s)")
143
+ print(f"Average: {elapsed/len(texts)*1000:.1f}ms/item")
144
+
145
+ # Save test file
146
+ service.synthesize_to_file(text, "/tmp/soprano_test.wav")
147
+ print(f"Saved test audio to /tmp/soprano_test.wav")
148
+
149
+ return service
150
+
151
+
152
+ if __name__ == "__main__":
153
+ test_soprano_service()