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 +1 -0
- datasets/verify_dataset.py +146 -0
- inference.py +324 -0
- services/gemma_service.py +188 -0
- services/matcha_service.py +196 -0
- services/piper_service.py +132 -0
- services/soprano_service.py +153 -0
.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()
|