| |
| """ |
| Passo 4: Inference - Speech-to-Speech |
| Load trained model and generate audio responses from audio input. |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import torch |
| import torchaudio |
| import numpy as np |
| from pathlib import Path |
|
|
| |
| SNAC_BASE = 128266 |
| SNAC_MAX = SNAC_BASE + 7 * 4096 |
| EOS_TOKEN = 128009 |
|
|
| def load_models(checkpoint_path: str, device: str = "cuda"): |
| """Load all models for inference.""" |
| from transformers import WhisperModel, WhisperFeatureExtractor, AutoTokenizer |
| import snac |
|
|
| print("Loading models...") |
|
|
| |
| print(" Whisper encoder...") |
| whisper_model = WhisperModel.from_pretrained("openai/whisper-large-v3").to(device) |
| whisper_model.eval() |
| feature_extractor = WhisperFeatureExtractor.from_pretrained("openai/whisper-large-v3") |
|
|
| |
| print(f" Loading checkpoint: {checkpoint_path}") |
| checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) |
|
|
| |
| print(f" Checkpoint keys: {list(checkpoint.keys())}") |
|
|
| |
| print(" Loading LLM...") |
| from transformers import AutoModelForCausalLM |
|
|
| llm = AutoModelForCausalLM.from_pretrained( |
| "canopylabs/3b-es_it-ft-research_release", |
| torch_dtype=torch.bfloat16, |
| device_map=device |
| ) |
|
|
| |
| lora_loaded = False |
| for lora_key in ['lora', 'lora_state_dict']: |
| if lora_key in checkpoint: |
| print(f" Found LoRA weights with key '{lora_key}'") |
| from peft import LoraConfig, get_peft_model |
| lora_config = LoraConfig( |
| r=16, |
| lora_alpha=32, |
| target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], |
| lora_dropout=0.0, |
| bias="none", |
| task_type="CAUSAL_LM" |
| ) |
| llm = get_peft_model(llm, lora_config) |
|
|
| |
| lora_state = checkpoint[lora_key] |
| print(f" LoRA state dict has {len(lora_state)} keys") |
| if len(lora_state) > 0: |
| print(f" Sample keys: {list(lora_state.keys())[:3]}") |
|
|
| |
| result = llm.load_state_dict(lora_state, strict=False) |
| print(f" LoRA load - Missing: {len(result.missing_keys)}, Unexpected: {len(result.unexpected_keys)}") |
| if result.missing_keys: |
| print(f" Missing keys (first 3): {result.missing_keys[:3]}") |
| if result.unexpected_keys: |
| print(f" Unexpected keys (first 3): {result.unexpected_keys[:3]}") |
|
|
| lora_loaded = True |
| break |
|
|
| if not lora_loaded: |
| print(" WARNING: No LoRA weights found in checkpoint! Model will use base weights only.") |
| print(" This might result in no SNAC token generation.") |
|
|
| llm.eval() |
|
|
| |
| print(" Loading Speech Adapter...") |
| from passo2_finetune_stage1 import SpeechAdapter |
| adapter = SpeechAdapter( |
| whisper_dim=1280, |
| llm_dim=3072, |
| downsample=5 |
| ).to(device) |
|
|
| adapter_loaded = False |
| for adapter_key in ['adapter', 'adapter_state_dict']: |
| if adapter_key in checkpoint: |
| print(f" Found adapter weights with key '{adapter_key}'") |
| result = adapter.load_state_dict(checkpoint[adapter_key], strict=False) |
| print(f" Adapter load - Missing: {len(result.missing_keys)}, Unexpected: {len(result.unexpected_keys)}") |
| adapter_loaded = True |
| break |
|
|
| if not adapter_loaded: |
| print(" WARNING: No adapter weights found in checkpoint!") |
|
|
| adapter.eval() |
|
|
| |
| print(" SNAC decoder...") |
| snac_model = snac.SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device) |
| snac_model.eval() |
|
|
| |
| tokenizer = AutoTokenizer.from_pretrained("canopylabs/3b-es_it-ft-research_release") |
|
|
| |
| print(f" Tokenizer vocab size: {tokenizer.vocab_size}") |
| print(f" BOS token: {tokenizer.bos_token} (id={tokenizer.bos_token_id})") |
| print(f" EOS token: {tokenizer.eos_token} (id={tokenizer.eos_token_id})") |
| print(f" SNAC token range: {SNAC_BASE} to {SNAC_BASE + 3*4096 - 1}") |
|
|
| print("Models loaded!") |
| return whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer |
|
|
|
|
| def encode_audio(audio_path: str, whisper_model, feature_extractor, adapter, device: str): |
| """Encode input audio to embeddings.""" |
| |
| waveform, sr = torchaudio.load(audio_path) |
| if sr != 16000: |
| waveform = torchaudio.functional.resample(waveform, sr, 16000) |
|
|
| |
| with torch.no_grad(): |
| inputs = feature_extractor(waveform.squeeze().numpy(), sampling_rate=16000, return_tensors="pt") |
| whisper_features = whisper_model.encoder( |
| inputs.input_features.to(device) |
| ).last_hidden_state |
|
|
| |
| adapted = adapter(whisper_features) |
|
|
| return adapted.to(torch.bfloat16) |
|
|
|
|
| def decode_snac_tokens(tokens: list, snac_model, device: str): |
| """Decode SNAC tokens to audio waveform. |
| |
| SNAC uses 3 hierarchical layers with 1:2:4 ratio. |
| Each "frame" has 7 tokens in order: |
| - 1 token from layer 0 (position 0) |
| - 2 tokens from layer 1 (positions 1, 2) |
| - 4 tokens from layer 2 (positions 3, 4, 5, 6) |
| |
| Tokens are offset by: SNAC_BASE + (position % 7) * 4096 |
| """ |
| if len(tokens) == 0: |
| print(" Warning: No SNAC tokens to decode") |
| return np.zeros(24000, dtype=np.float32) |
|
|
| layer0_tokens = [] |
| layer1_tokens = [] |
| layer2_tokens = [] |
|
|
| |
| for i, tok in enumerate(tokens): |
| pos = i % 7 |
| |
| original = tok - SNAC_BASE - (pos * 4096) |
|
|
| |
| if original < 0 or original >= 4096: |
| print(f" Warning: Invalid code {original} at position {i} (token={tok}, pos={pos})") |
| original = max(0, min(4095, original)) |
|
|
| if pos == 0: |
| layer0_tokens.append(original) |
| elif pos in [1, 2]: |
| layer1_tokens.append(original) |
| else: |
| layer2_tokens.append(original) |
|
|
| |
| n_frames = len(tokens) // 7 |
|
|
| if n_frames == 0: |
| print(f" Warning: Not enough tokens for a complete frame ({len(tokens)} tokens)") |
| return np.zeros(24000, dtype=np.float32) |
|
|
| print(f" Decoding {n_frames} frames ({len(layer0_tokens)} L0, {len(layer1_tokens)} L1, {len(layer2_tokens)} L2)") |
|
|
| |
| layer0_tokens = layer0_tokens[:n_frames] |
| layer1_tokens = layer1_tokens[:n_frames * 2] |
| layer2_tokens = layer2_tokens[:n_frames * 4] |
|
|
| codes = [ |
| torch.tensor([layer0_tokens], dtype=torch.long, device=device), |
| torch.tensor([layer1_tokens], dtype=torch.long, device=device), |
| torch.tensor([layer2_tokens], dtype=torch.long, device=device) |
| ] |
|
|
| |
| with torch.no_grad(): |
| audio = snac_model.decode(codes) |
|
|
| return audio.squeeze().cpu().numpy() |
|
|
|
|
| def generate_response( |
| audio_input: str, |
| whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer, |
| device: str, |
| max_new_tokens: int = 500, |
| temperature: float = 0.7, |
| debug: bool = True, |
| use_prompt: bool = False |
| ): |
| """Generate speech response from audio input.""" |
|
|
| |
| print("Encoding input audio...") |
| audio_embeddings = encode_audio(audio_input, whisper_model, feature_extractor, adapter, device) |
| print(f" Audio embeddings shape: {audio_embeddings.shape}") |
|
|
| |
| print("Generating response...") |
|
|
| with torch.no_grad(): |
| |
| embed_layer = llm.get_input_embeddings() |
|
|
| if use_prompt: |
| |
| bos_id = tokenizer.bos_token_id or 128000 |
| prompt_tokens = torch.tensor([[bos_id]], dtype=torch.long, device=device) |
| prompt_embeds = embed_layer(prompt_tokens) |
| input_embeds = torch.cat([audio_embeddings.to(torch.bfloat16), prompt_embeds.to(torch.bfloat16)], dim=1) |
| else: |
| |
| |
| input_embeds = audio_embeddings.to(torch.bfloat16) |
|
|
| print(f" Input embeds shape: {input_embeds.shape}") |
|
|
| |
| attention_mask = torch.ones(input_embeds.shape[:2], dtype=torch.long, device=device) |
|
|
| |
| |
| if temperature <= 0.01: |
| outputs = llm.generate( |
| inputs_embeds=input_embeds, |
| attention_mask=attention_mask, |
| max_new_tokens=max_new_tokens, |
| do_sample=False, |
| pad_token_id=tokenizer.eos_token_id, |
| eos_token_id=EOS_TOKEN, |
| use_cache=True |
| ) |
| |
| else: |
| outputs = llm.generate( |
| inputs_embeds=input_embeds, |
| attention_mask=attention_mask, |
| max_new_tokens=max_new_tokens, |
| temperature=temperature, |
| do_sample=True, |
| top_p=0.9, |
| top_k=50, |
| repetition_penalty=1.1, |
| pad_token_id=tokenizer.eos_token_id, |
| eos_token_id=EOS_TOKEN, |
| use_cache=True |
| ) |
|
|
| |
| generated_tokens = outputs[0].tolist() |
|
|
| if debug: |
| print(f"\n=== DEBUG: Generated {len(generated_tokens)} total tokens ===") |
| |
| print(f"First 50 tokens: {generated_tokens[:50]}") |
| |
| print(f"Last 20 tokens: {generated_tokens[-20:]}") |
|
|
| |
| text_tokens = [t for t in generated_tokens if t < SNAC_BASE] |
| snac_range_tokens = [t for t in generated_tokens if SNAC_BASE <= t < SNAC_MAX] |
| other_high = [t for t in generated_tokens if t >= SNAC_MAX] |
|
|
| print(f"\nToken distribution:") |
| print(f" Text tokens (<{SNAC_BASE}): {len(text_tokens)}") |
| print(f" SNAC tokens ({SNAC_BASE}-{SNAC_MAX}): {len(snac_range_tokens)}") |
| print(f" Other high tokens (>={SNAC_MAX}): {len(other_high)}") |
|
|
| |
| if text_tokens: |
| try: |
| decoded_text = tokenizer.decode(text_tokens, skip_special_tokens=False) |
| print(f"\nDecoded text: {decoded_text[:500]}") |
| except Exception as e: |
| print(f"Could not decode text: {e}") |
|
|
| |
| if snac_range_tokens: |
| print(f"\nFirst 20 SNAC tokens: {snac_range_tokens[:20]}") |
|
|
| |
| snac_tokens = [t for t in generated_tokens if SNAC_BASE <= t < SNAC_MAX] |
|
|
| print(f"\nGenerated {len(snac_tokens)} SNAC tokens") |
|
|
| |
| print("Decoding to audio...") |
| audio_output = decode_snac_tokens(snac_tokens, snac_model, device) |
|
|
| return audio_output |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Speech-to-Speech Inference") |
| parser.add_argument("--checkpoint", type=str, required=True, help="Path to model checkpoint") |
| parser.add_argument("--input", type=str, required=True, help="Input audio file") |
| parser.add_argument("--output", type=str, default="output.wav", help="Output audio file") |
| parser.add_argument("--max_tokens", type=int, default=1000, help="Max tokens to generate") |
| parser.add_argument("--temperature", type=float, default=0.7, help="Sampling temperature (0 for greedy)") |
| parser.add_argument("--use_prompt", action="store_true", help="Add BOS token after audio embeddings") |
| parser.add_argument("--no_debug", action="store_true", help="Disable debug output") |
| args = parser.parse_args() |
|
|
| device = "cuda" if torch.cuda.is_available() else "cpu" |
| print(f"Device: {device}") |
|
|
| |
| whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer = load_models( |
| args.checkpoint, device |
| ) |
|
|
| |
| audio_output = generate_response( |
| args.input, |
| whisper_model, feature_extractor, llm, adapter, snac_model, tokenizer, |
| device, |
| max_new_tokens=args.max_tokens, |
| temperature=args.temperature, |
| debug=not args.no_debug, |
| use_prompt=args.use_prompt |
| ) |
|
|
| |
| import soundfile as sf |
| sf.write(args.output, audio_output, 24000) |
| print(f"Saved output to: {args.output}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|