| |
| """ |
| Inference script for Speech-to-Speech model. |
| |
| Usage: |
| python inference.py --checkpoint ./checkpoints/stage2_best.pt --input audio.wav --output response.wav |
| python inference.py --checkpoint ./checkpoints/stage2_best.pt --text "Hello, how are you?" |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import torch |
| import torch.nn as nn |
| import numpy as np |
|
|
| |
| SNAC_BASE_OFFSET = 128266 |
| EOS_TOKEN = 128009 |
|
|
|
|
| class SpeechAdapter(nn.Module): |
| """Same architecture as training - must match exactly.""" |
| def __init__(self, whisper_dim=1280, llm_dim=3072, downsample=5, intermediate_dim=2048): |
| super().__init__() |
| self.downsample = downsample |
| concat_dim = whisper_dim * downsample |
|
|
| self.ffn = nn.Sequential( |
| nn.Linear(concat_dim, intermediate_dim), |
| nn.GELU(), |
| nn.Linear(intermediate_dim, llm_dim), |
| nn.LayerNorm(llm_dim) |
| ) |
|
|
| def forward(self, x): |
| B, T, D = x.shape |
| T_new = (T // self.downsample) * self.downsample |
| x = x[:, :T_new] |
| x = x.reshape(B, T_new // self.downsample, D * self.downsample) |
| return self.ffn(x) |
|
|
|
|
| def decode_snac_tokens(snac_tokens, device="cuda"): |
| """Decode SNAC tokens to audio waveform.""" |
| try: |
| from snac import SNAC |
|
|
| |
| snac = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device) |
| snac.eval() |
|
|
| |
| raw_tokens = [] |
| for i, tok in enumerate(snac_tokens): |
| pos = i % 7 |
| offset = SNAC_BASE_OFFSET + pos * 4096 |
| raw_tok = tok - offset |
| if 0 <= raw_tok < 4096: |
| raw_tokens.append(raw_tok) |
|
|
| if len(raw_tokens) == 0: |
| return None, 24000 |
|
|
| |
| num_frames = len(raw_tokens) // 7 |
| if num_frames == 0: |
| return None, 24000 |
|
|
| raw_tokens = raw_tokens[:num_frames * 7] |
|
|
| |
| |
| codes = [] |
| for frame_idx in range(num_frames): |
| base = frame_idx * 7 |
| codes.append(raw_tokens[base:base+7]) |
|
|
| codes = torch.tensor(codes, device=device) |
|
|
| |
| layer0 = codes[:, 0:1].T |
| layer1 = codes[:, 1:3].T.reshape(1, -1) |
| layer2 = codes[:, 3:7].T.reshape(1, -1) |
|
|
| with torch.no_grad(): |
| audio = snac.decode([layer0, layer1, layer2]) |
|
|
| return audio.cpu().numpy().squeeze(), 24000 |
|
|
| except Exception as e: |
| print(f"SNAC decode error: {e}") |
| return None, 24000 |
|
|
|
|
| def extract_whisper_features(audio_path, device="cuda"): |
| """Extract Whisper encoder features from audio file.""" |
| try: |
| from transformers import WhisperProcessor, WhisperModel |
| import librosa |
|
|
| |
| audio, sr = librosa.load(audio_path, sr=16000) |
|
|
| |
| processor = WhisperProcessor.from_pretrained("openai/whisper-large-v3") |
| model = WhisperModel.from_pretrained("openai/whisper-large-v3").to(device) |
| model.eval() |
|
|
| |
| inputs = processor(audio, sampling_rate=16000, return_tensors="pt") |
| input_features = inputs.input_features.to(device) |
|
|
| with torch.no_grad(): |
| encoder_outputs = model.encoder(input_features) |
| features = encoder_outputs.last_hidden_state |
|
|
| return features |
|
|
| except Exception as e: |
| print(f"Whisper feature extraction error: {e}") |
| return None |
|
|
|
|
| def generate_response(model, adapter, tokenizer, audio_embeds, device, max_new_tokens=500): |
| """Generate interleaved text+audio response.""" |
|
|
| |
| if hasattr(model, 'get_base_model'): |
| base_model = model.get_base_model() |
| else: |
| base_model = model |
|
|
| |
| generated_tokens = [] |
|
|
| |
| current_embeds = audio_embeds |
|
|
| with torch.no_grad(): |
| for step in range(max_new_tokens): |
| |
| outputs = model(inputs_embeds=current_embeds, use_cache=False) |
| logits = outputs.logits |
|
|
| |
| next_token_logits = logits[:, -1, :] |
| next_token = torch.argmax(next_token_logits, dim=-1) |
|
|
| token_id = next_token.item() |
| generated_tokens.append(token_id) |
|
|
| |
| if token_id == EOS_TOKEN: |
| break |
|
|
| |
| if hasattr(base_model, 'model'): |
| next_embed = base_model.model.embed_tokens(next_token.unsqueeze(0)) |
| else: |
| next_embed = base_model.embed_tokens(next_token.unsqueeze(0)) |
|
|
| |
| current_embeds = torch.cat([current_embeds, next_embed], dim=1) |
|
|
| |
| if current_embeds.shape[1] > 2048: |
| current_embeds = current_embeds[:, -2048:] |
|
|
| return generated_tokens |
|
|
|
|
| def separate_tokens(generated_tokens): |
| """Separate text and audio tokens from interleaved output.""" |
| text_tokens = [] |
| audio_tokens = [] |
|
|
| for tok in generated_tokens: |
| if tok >= SNAC_BASE_OFFSET: |
| audio_tokens.append(tok) |
| elif tok != EOS_TOKEN: |
| text_tokens.append(tok) |
|
|
| return text_tokens, audio_tokens |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="Speech-to-Speech Inference") |
| parser.add_argument("--checkpoint", type=str, required=True, help="Path to checkpoint (stage1 or stage2)") |
| parser.add_argument("--input", type=str, default=None, help="Input audio file") |
| parser.add_argument("--text", type=str, default=None, help="Input text (for testing without audio)") |
| parser.add_argument("--output", type=str, default="./output.wav", help="Output audio file") |
| parser.add_argument("--model_path", type=str, default="canopylabs/3b-es_it-ft-research_release") |
| parser.add_argument("--max_tokens", type=int, default=500) |
| parser.add_argument("--device", type=str, default=None) |
| args = parser.parse_args() |
|
|
| |
| if args.device: |
| device = torch.device(args.device) |
| elif torch.cuda.is_available(): |
| device = torch.device("cuda") |
| elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): |
| device = torch.device("mps") |
| else: |
| device = torch.device("cpu") |
|
|
| print(f"Device: {device}") |
|
|
| |
| torch_dtype = torch.bfloat16 if device.type == 'cuda' else torch.float32 |
|
|
| |
| print(f"Loading tokenizer: {args.model_path}") |
| from transformers import AutoTokenizer, AutoModelForCausalLM |
| tokenizer = AutoTokenizer.from_pretrained(args.model_path) |
|
|
| |
| print(f"Loading checkpoint: {args.checkpoint}") |
| ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=False) |
|
|
| has_lora = "lora" in ckpt |
| print(f"Checkpoint type: {'Stage 2 (Adapter + LoRA)' if has_lora else 'Stage 1 (Adapter only)'}") |
|
|
| |
| print(f"Loading LLM: {args.model_path}") |
| llm = AutoModelForCausalLM.from_pretrained( |
| args.model_path, |
| torch_dtype=torch_dtype, |
| attn_implementation="sdpa", |
| ).to(device) |
|
|
| |
| if has_lora: |
| from peft import LoraConfig, get_peft_model, TaskType |
| 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=TaskType.CAUSAL_LM |
| ) |
| llm = get_peft_model(llm, lora_config) |
| llm.load_state_dict(ckpt["lora"], strict=False) |
| print("LoRA weights loaded") |
|
|
| llm.eval() |
|
|
| |
| print("Loading adapter...") |
| adapter = SpeechAdapter( |
| whisper_dim=1280, |
| llm_dim=3072, |
| downsample=5, |
| intermediate_dim=2048 |
| ).to(device, dtype=torch_dtype) |
| adapter.load_state_dict(ckpt["adapter"]) |
| adapter.eval() |
| print("Adapter loaded") |
|
|
| |
| if args.input: |
| print(f"Processing audio: {args.input}") |
| whisper_features = extract_whisper_features(args.input, device) |
| if whisper_features is None: |
| print("Failed to extract Whisper features") |
| return |
| audio_embeds = adapter(whisper_features.to(torch_dtype)) |
|
|
| elif args.text: |
| print(f"Processing text: {args.text}") |
| |
| |
| dummy_features = torch.randn(1, 100, 1280, device=device, dtype=torch_dtype) |
| audio_embeds = adapter(dummy_features) |
|
|
| |
| text_tokens = tokenizer.encode(args.text, add_special_tokens=False) |
| print(f"Text tokens: {text_tokens[:10]}...") |
| else: |
| print("ERROR: Provide --input (audio file) or --text") |
| return |
|
|
| print(f"Audio embeddings shape: {audio_embeds.shape}") |
|
|
| |
| print(f"Generating response (max {args.max_tokens} tokens)...") |
| generated_tokens = generate_response( |
| llm, adapter, tokenizer, audio_embeds, device, |
| max_new_tokens=args.max_tokens |
| ) |
|
|
| print(f"Generated {len(generated_tokens)} tokens") |
|
|
| |
| text_tokens, audio_tokens = separate_tokens(generated_tokens) |
| print(f"Text tokens: {len(text_tokens)}, Audio tokens: {len(audio_tokens)}") |
|
|
| |
| if text_tokens: |
| decoded_text = tokenizer.decode(text_tokens, skip_special_tokens=True) |
| print(f"\nGenerated text: {decoded_text}") |
|
|
| |
| if audio_tokens: |
| print(f"\nDecoding {len(audio_tokens)} audio tokens...") |
| audio, sr = decode_snac_tokens(audio_tokens, device) |
|
|
| if audio is not None: |
| import soundfile as sf |
| sf.write(args.output, audio, sr) |
| print(f"Audio saved: {args.output}") |
| else: |
| print("Failed to decode audio") |
| else: |
| print("No audio tokens generated") |
|
|
| |
| print(f"\nFirst 20 generated tokens: {generated_tokens[:20]}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|