From 56cb11e7b2fd32b832c7e868a3273c56f6b1161a Mon Sep 17 00:00:00 2001 From: Zhiliang Peng <1083127130@qq.com> Date: Wed, 21 Jan 2026 22:18:33 +0800 Subject: [PATCH] Add VibeVoice-ASR --- Figures/VibeVoice_ASR_archi.png | Bin 0 -> 167541 bytes README.md | 5 +- demo/vibevoice_asr_gradio_demo.py | 1177 +++++++++++++++++ demo/vibevoice_asr_inference_from_file.py | 554 ++++++++ docs/vibevoice-asr.md | 62 + pyproject.toml | 26 +- vibevoice/modular/configuration_vibevoice.py | 105 +- vibevoice/modular/modeling_vibevoice.py | 496 +++++++ vibevoice/modular/modeling_vibevoice_asr.py | 520 ++++++++ .../modular_vibevoice_text_tokenizer.py | 99 ++ .../modular/modular_vibevoice_tokenizer.py | 323 ++++- vibevoice/processor/audio_utils.py | 143 ++ .../processor/vibevoice_asr_processor.py | 572 ++++++++ .../vibevoice_tokenizer_processor.py | 74 +- 14 files changed, 4062 insertions(+), 94 deletions(-) create mode 100644 Figures/VibeVoice_ASR_archi.png create mode 100644 demo/vibevoice_asr_gradio_demo.py create mode 100644 demo/vibevoice_asr_inference_from_file.py create mode 100644 docs/vibevoice-asr.md create mode 100644 vibevoice/modular/modeling_vibevoice.py create mode 100644 vibevoice/modular/modeling_vibevoice_asr.py create mode 100644 vibevoice/processor/audio_utils.py create mode 100644 vibevoice/processor/vibevoice_asr_processor.py diff --git a/Figures/VibeVoice_ASR_archi.png b/Figures/VibeVoice_ASR_archi.png new file mode 100644 index 0000000000000000000000000000000000000000..0d24aff0dfbc781bd27584422a5ae83955dfa9bc GIT binary patch literal 167541 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

+Papx}42UFA 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 4c4e68a..41a98de 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ New Realtime TTS +2026-01-21: 📣 We open-sourced VibeVoice-ASR, a unified speech-to-text model designed to handle 60-minute long-form audio in a single pass, generating structured transcriptions containing Who (Speaker), When (Timestamps), and What (Content), with support for User-Customized Context. -2025-12-16: 📣 We added more experimental speakers for exploration, including multilingual voices and 11 distinct English style voices. [Try it](docs/vibevoice-realtime-0.5b.md#optional-more-experimental-voices). More speaker types will be added over time. +2025-12-16: 📣 We added more experimental speakers for exploration, including multilingual voices and 11 distinct English style voices. [Try it](docs/vibevoice-realtime-0.5b.md#optional-more-experimental-voices). More speaker types will be added over time. 2025-12-09: 📣 We added experimental speakers in nine languages (DE, FR, IT, JP, KR, NL, PL, PT, ES) for exploration—welcome to try them out and share your feedback. @@ -123,4 +124,4 @@ We do not recommend using VibeVoice in commercial or real-world applications wit ## Star History -![Star History Chart](https://api.star-history.com/svg?repos=Microsoft/vibevoice&type=date&legend=top-left) \ No newline at end of file +![Star History Chart](https://api.star-history.com/svg?repos=Microsoft/vibevoice&type=date&legend=top-left) diff --git a/demo/vibevoice_asr_gradio_demo.py b/demo/vibevoice_asr_gradio_demo.py new file mode 100644 index 0000000..c334f95 --- /dev/null +++ b/demo/vibevoice_asr_gradio_demo.py @@ -0,0 +1,1177 @@ +#!/usr/bin/env python +""" +VibeVoice ASR Gradio Demo +""" + +import os +import sys +import torch +import numpy as np +import soundfile as sf +from pathlib import Path +import argparse +import time +import json +import gradio as gr +from typing import List, Dict, Tuple, Optional, Generator +import tempfile +import base64 +import io +import traceback +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +# Import TextIteratorStreamer for streaming generation +from transformers import TextIteratorStreamer, StoppingCriteria, StoppingCriteriaList + +try: + from liger_kernel.transformers import apply_liger_kernel_to_qwen2 + # Only apply RoPE, RMSNorm, SwiGLU patches (these affect the underlying Qwen2 layers) + apply_liger_kernel_to_qwen2( + rope=True, + rms_norm=True, + swiglu=True, + cross_entropy=False, + ) + print("✅ Liger Kernel applied to Qwen2 components (RoPE, RMSNorm, SwiGLU)") +except Exception as e: + print(f"⚠️ Failed to apply Liger Kernel: {e}, you can install it with: pip install liger-kernel") + +# Try to import pydub for MP3 conversion +try: + from pydub import AudioSegment + HAS_PYDUB = True +except ImportError: + HAS_PYDUB = False + print("⚠️ Warning: pydub not available, falling back to WAV format") + +from vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration +from vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor +from vibevoice.processor.audio_utils import load_audio_use_ffmpeg, COMMON_AUDIO_EXTS + + +class VibeVoiceASRInference: + """Simple inference wrapper for VibeVoice ASR model.""" + + def __init__(self, model_path: str, device: str = "cuda", dtype: torch.dtype = torch.bfloat16, attn_implementation: str = "flash_attention_2"): + """ + Initialize the ASR inference pipeline. + + Args: + model_path: Path to the pretrained model (HuggingFace format directory or model name) + device: Device to run inference on + dtype: Data type for model weights + attn_implementation: Attention implementation to use ('flash_attention_2', 'sdpa', 'eager') + """ + print(f"Loading VibeVoice ASR model from {model_path}") + + # Load processor + self.processor = VibeVoiceASRProcessor.from_pretrained(model_path) + + # Load model + print(f"Using attention implementation: {attn_implementation}") + self.model = VibeVoiceASRForConditionalGeneration.from_pretrained( + model_path, + dtype=dtype, + device_map=device if device == "auto" else None, + attn_implementation=attn_implementation, + trust_remote_code=True + ) + + if device != "auto": + self.model = self.model.to(device) + + self.device = device if device != "auto" else next(self.model.parameters()).device + self.model.eval() + + # Print model info + total_params = sum(p.numel() for p in self.model.parameters()) + print(f"✅ Model loaded successfully on {self.device}") + print(f"📊 Total parameters: {total_params:,} ({total_params/1e9:.2f}B)") + + def transcribe( + self, + audio_path: str = None, + audio_array: np.ndarray = None, + sample_rate: int = None, + max_new_tokens: int = 512, + temperature: float = 0.0, + top_p: float = 1.0, + do_sample: bool = False, + num_beams: int = 1, + repetition_penalty: float = 1.0, + context_info: str = None, + streamer: Optional[TextIteratorStreamer] = None, + ) -> dict: + """ + Transcribe audio to text. + + Args: + audio_path: Path to audio file + audio_array: Audio array (if not loading from file) + sample_rate: Sample rate of audio array + max_new_tokens: Maximum tokens to generate + temperature: Temperature for sampling (0 for greedy) + top_p: Top-p for nucleus sampling (1.0 for no filtering) + do_sample: Whether to use sampling + num_beams: Number of beams for beam search (1 for greedy) + repetition_penalty: Repetition penalty (1.0 for no penalty) + context_info: Optional context information (e.g., hotwords, speaker names, topics) to help transcription + streamer: Optional TextIteratorStreamer for streaming output + + Returns: + Dictionary with transcription results + """ + # Process audio + inputs = self.processor( + audio=audio_path, + sampling_rate=sample_rate, + return_tensors="pt", + add_generation_prompt=True, + context_info=context_info + ) + + # Move to device + inputs = {k: v.to(self.device) if isinstance(v, torch.Tensor) else v + for k, v in inputs.items()} + + # Generate + generation_config = { + "max_new_tokens": max_new_tokens, + "temperature": temperature if temperature > 0 else None, + "top_p": top_p if do_sample else None, + "do_sample": do_sample, + "num_beams": num_beams, + "repetition_penalty": repetition_penalty, + "pad_token_id": self.processor.pad_id, + "eos_token_id": self.processor.tokenizer.eos_token_id, + } + + # Add streamer if provided + if streamer is not None: + generation_config["streamer"] = streamer + + # Add stopping criteria for stop button support + generation_config["stopping_criteria"] = StoppingCriteriaList([StopOnFlag()]) + + # Remove None values + generation_config = {k: v for k, v in generation_config.items() if v is not None} + + start_time = time.time() + + # Calculate input token statistics before generation + input_ids = inputs['input_ids'][0] # Shape: [seq_len] + total_input_tokens = input_ids.shape[0] + + # Count padding tokens (tokens equal to pad_id) + pad_id = self.processor.pad_id + padding_mask = (input_ids == pad_id) + num_padding_tokens = padding_mask.sum().item() + + # Count speech tokens (tokens between speech_start_id and speech_end_id) + speech_start_id = self.processor.speech_start_id + speech_end_id = self.processor.speech_end_id + + # Find speech regions + input_ids_list = input_ids.tolist() + num_speech_tokens = 0 + in_speech = False + for token_id in input_ids_list: + if token_id == speech_start_id: + in_speech = True + num_speech_tokens += 1 # Count speech_start token + elif token_id == speech_end_id: + in_speech = False + num_speech_tokens += 1 # Count speech_end token + elif in_speech: + num_speech_tokens += 1 + + # Text tokens = total - speech - padding + num_text_tokens = total_input_tokens - num_speech_tokens - num_padding_tokens + + with torch.no_grad(): + output_ids = self.model.generate( + **inputs, + **generation_config + ) + + generation_time = time.time() - start_time + + # Decode output + generated_ids = output_ids[0, inputs['input_ids'].shape[1]:] + generated_text = self.processor.decode(generated_ids, skip_special_tokens=True) + + # Parse structured output + try: + transcription_segments = self.processor.post_process_transcription(generated_text) + except Exception as e: + print(f"Warning: Failed to parse structured output: {e}") + transcription_segments = [] + + return { + "raw_text": generated_text, + "segments": transcription_segments, + "generation_time": generation_time, + "input_tokens": { + "total": total_input_tokens, + "speech": num_speech_tokens, + "text": num_text_tokens, + "padding": num_padding_tokens, + }, + } + + +def clip_and_encode_audio( + audio_data: np.ndarray, + sr: int, + start_time: float, + end_time: float, + segment_idx: int, + use_mp3: bool = True, + target_sr: int = 16000, # Downsample to 16kHz for smaller size + mp3_bitrate: str = "32k" # Use low bitrate for minimal transfer +) -> Tuple[int, Optional[str], Optional[str]]: + """ + Clip audio segment and encode to base64. + + Args: + audio_data: Full audio array + sr: Sample rate + start_time: Start time in seconds + end_time: End time in seconds + segment_idx: Segment index for identification + use_mp3: Whether to use MP3 format (smaller size) + target_sr: Target sample rate for downsampling (lower = smaller) + mp3_bitrate: MP3 bitrate (lower = smaller, e.g., "24k", "32k", "48k") + + Returns: + Tuple of (segment_idx, base64_string, error_message) + """ + try: + # Convert time to sample indices + start_sample = int(start_time * sr) + end_sample = int(end_time * sr) + + # Ensure indices are within bounds + start_sample = max(0, start_sample) + end_sample = min(len(audio_data), end_sample) + + if start_sample >= end_sample: + return segment_idx, None, f"Invalid time range: [{start_time:.2f}s - {end_time:.2f}s]" + + # Extract segment + segment_data = audio_data[start_sample:end_sample] + + # Downsample if needed (reduces data size significantly) + if sr != target_sr and target_sr < sr: + # Simple downsampling using linear interpolation + duration = len(segment_data) / sr + new_length = int(duration * target_sr) + indices = np.linspace(0, len(segment_data) - 1, new_length) + segment_data = np.interp(indices, np.arange(len(segment_data)), segment_data) + sr = target_sr + + # Convert float32 audio to int16 for encoding + segment_data_int16 = (segment_data * 32768.0).astype(np.int16) + + # Convert to MP3 if pydub is available and use_mp3 is True + if use_mp3 and HAS_PYDUB: + try: + # Write to WAV in memory + wav_buffer = io.BytesIO() + sf.write(wav_buffer, segment_data_int16, sr, format='WAV', subtype='PCM_16') + wav_buffer.seek(0) + + # Convert to MP3 with low bitrate + audio_segment = AudioSegment.from_wav(wav_buffer) + # Convert to mono if stereo (halves the size) + if audio_segment.channels > 1: + audio_segment = audio_segment.set_channels(1) + mp3_buffer = io.BytesIO() + audio_segment.export(mp3_buffer, format='mp3', bitrate=mp3_bitrate) + mp3_buffer.seek(0) + + # Encode to base64 + audio_bytes = mp3_buffer.read() + audio_base64 = base64.b64encode(audio_bytes).decode('utf-8') + audio_src = f"data:audio/mp3;base64,{audio_base64}" + + return segment_idx, audio_src, None + except Exception as e: + # Fall back to WAV on error + print(f"MP3 conversion failed for segment {segment_idx}, using WAV: {e}") + + # Fall back to WAV format (no temp file, use in-memory buffer) + wav_buffer = io.BytesIO() + sf.write(wav_buffer, segment_data_int16, sr, format='WAV', subtype='PCM_16') + wav_buffer.seek(0) + + audio_bytes = wav_buffer.read() + audio_base64 = base64.b64encode(audio_bytes).decode('utf-8') + audio_src = f"data:audio/wav;base64,{audio_base64}" + + return segment_idx, audio_src, None + + except Exception as e: + error_msg = f"Error clipping segment {segment_idx}: {str(e)}" + print(error_msg) + return segment_idx, None, error_msg + + +def extract_audio_segments(audio_path: str, segments: List[Dict]) -> List[Tuple[str, str, Optional[str]]]: + """ + Extract multiple segments from audio file efficiently with parallel processing. + + Args: + audio_path: Path to original audio file + segments: List of segment dictionaries with start_time, end_time, etc. + + Returns: + List of tuples (segment_label, audio_base64_src, error_msg) + """ + try: + # Read audio file once using ffmpeg for better format support + print(f"📂 Loading audio file: {audio_path}") + audio_data, sr = load_audio_use_ffmpeg(audio_path, resample=False) + print(f"✅ Audio loaded: {len(audio_data)} samples, {sr} Hz") + + # Prepare tasks + tasks = [] + use_mp3 = HAS_PYDUB # Use MP3 if available + + for i, seg in enumerate(segments): + start_time = seg.get('start_time') + end_time = seg.get('end_time') + + # Skip if times are not available or invalid + if (not isinstance(start_time, (int, float)) or + not isinstance(end_time, (int, float)) or + start_time >= end_time): + tasks.append((i, None, None, None, None, None)) # Will be filtered later + continue + + tasks.append((audio_data, sr, start_time, end_time, i, use_mp3)) + + # Process in parallel using ThreadPoolExecutor + results = [] + total_segments = len(tasks) + completed_count = 0 + + # Use CPU count for max workers + max_workers = os.cpu_count() or 4 + print(f"🚀 Starting parallel processing with {max_workers} threads...") + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {} + for task in tasks: + if task[0] is None: # Skip invalid tasks + continue + future = executor.submit(clip_and_encode_audio, *task) + futures[future] = task[4] # segment_idx + + for future in as_completed(futures): + try: + result = future.result() + results.append(result) + completed_count += 1 + # Log progress every 100 segments or at completion + if completed_count % 100 == 0 or completed_count == len(futures): + print(f"Progress: {completed_count}/{len(futures)} segments processed ({completed_count*100//len(futures)}%)") + except Exception as e: + idx = futures[future] + results.append((idx, None, f"Processing error: {str(e)}")) + completed_count += 1 + print(f"Error on segment {idx}: {e}") + + print(f"✅ Completed processing all {len(futures)} valid segments") + + # Sort by segment index to maintain order + results.sort(key=lambda x: x[0]) + + # Build output list with labels + audio_segments = [] + for i, (idx, audio_src, error_msg) in enumerate(results): + seg = segments[idx] if idx < len(segments) else {} + start_time = seg.get('start_time', 'N/A') + end_time = seg.get('end_time', 'N/A') + speaker_id = seg.get('speaker_id', 'N/A') + + segment_label = f"Segment {idx+1}: [{start_time:.2f}s - {end_time:.2f}s] Speaker {speaker_id}" + audio_segments.append((segment_label, audio_src, error_msg)) + + return audio_segments + + except Exception as e: + print(f"Error loading audio file: {e}") + return [] + + +# Global variable to store the ASR model +asr_model = None + +# Global stop flag for generation +stop_generation_flag = False + + +class StopOnFlag(StoppingCriteria): + """Custom stopping criteria that checks a global flag.""" + def __call__(self, input_ids, scores, **kwargs): + global stop_generation_flag + return stop_generation_flag + + +def parse_time_to_seconds(val: Optional[str]) -> Optional[float]: + """Parse seconds or hh:mm:ss to float seconds.""" + if val is None: + return None + val = val.strip() + if not val: + return None + try: + return float(val) + except ValueError: + pass + if ":" in val: + parts = val.split(":") + if not all(p.strip().replace(".", "", 1).isdigit() for p in parts): + return None + parts = [float(p) for p in parts] + if len(parts) == 3: + h, m, s = parts + elif len(parts) == 2: + h = 0 + m, s = parts + else: + return None + return h * 3600 + m * 60 + s + return None + + +def slice_audio_to_temp( + audio_data: np.ndarray, + sample_rate: int, + start_sec: Optional[float], + end_sec: Optional[float] +) -> Tuple[Optional[str], Optional[str]]: + """Slice audio_data to [start_sec, end_sec) and write to a temp WAV file.""" + n_samples = len(audio_data) + full_duration = n_samples / float(sample_rate) + start = 0.0 if start_sec is None else max(0.0, start_sec) + end = full_duration if end_sec is None else min(full_duration, end_sec) + if end <= start: + return None, f"Invalid time range: start={start:.2f}s, end={end:.2f}s" + start_idx = int(start * sample_rate) + end_idx = int(end * sample_rate) + segment = audio_data[start_idx:end_idx] + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + temp_file.close() + segment_int16 = (segment * 32768.0).astype(np.int16) + sf.write(temp_file.name, segment_int16, sample_rate, subtype='PCM_16') + return temp_file.name, None + + +def initialize_model(model_path: str, device: str = "cuda", attn_implementation: str = "flash_attention_2"): + """Initialize the ASR model.""" + global asr_model + try: + dtype = torch.bfloat16 if device != "cpu" else torch.float32 + asr_model = VibeVoiceASRInference( + model_path=model_path, + device=device, + dtype=dtype, + attn_implementation=attn_implementation + ) + return f"✅ Model loaded successfully from {model_path}" + except Exception as e: + import traceback + traceback.print_exc() + return f"❌ Error loading model: {str(e)}" + + +def transcribe_audio( + audio_input, + audio_path_input: str, + start_time_input: str, + end_time_input: str, + max_new_tokens: int, + temperature: float, + top_p: float, + do_sample: bool, + repetition_penalty: float = 1.0, + context_info: str = "" +) -> Generator[Tuple[str, str], None, None]: + """ + Transcribe audio and return results with audio segments (streaming version). + + Args: + audio_input: Audio file path or tuple (sample_rate, audio_data) + max_new_tokens: Maximum tokens to generate + temperature: Temperature for sampling (0 for greedy) + top_p: Top-p for nucleus sampling + do_sample: Whether to use sampling + context_info: Optional context information (e.g., hotwords, speaker names, topics) + + Yields: + Tuple of (raw_text, audio_segments_html) + """ + if asr_model is None: + yield "❌ Please load a model first!", "" + return + + if not audio_path_input and audio_input is None: + yield "❌ Please provide audio input!", "" + return + + try: + print("[INFO] Transcription requested") + start_sec = parse_time_to_seconds(start_time_input) + end_sec = parse_time_to_seconds(end_time_input) + print(f"[INFO] Parsed time range: start={start_sec}, end={end_sec}") + if (start_time_input and start_sec is None) or (end_time_input and end_sec is None): + yield "❌ Invalid time format. Use seconds or hh:mm:ss.", "" + return + + audio_path = None + audio_array = None + sample_rate = None + + if audio_path_input: + candidate = Path(audio_path_input.strip()) + if not candidate.exists(): + yield f"❌ Provided path does not exist: {candidate}", "" + return + audio_path = str(candidate) + print(f"[INFO] Using provided audio path: {audio_path}") + # Get audio file path (Gradio Audio component returns tuple (sample_rate, audio_data) or file path) + elif isinstance(audio_input, str): + audio_path = audio_input + print(f"[INFO] Using uploaded audio path: {audio_path}") + elif isinstance(audio_input, tuple): + # Audio from microphone: (sample_rate, audio_data) + sample_rate, audio_array = audio_input + print(f"[INFO] Received microphone audio with sample_rate={sample_rate}") + elif audio_path is None: + yield "❌ Invalid audio input format!", "" + return + + # If slicing is requested, load and slice audio + if start_sec is not None or end_sec is not None: + print("[INFO] Slicing audio per requested time range") + if audio_array is None or sample_rate is None: + try: + audio_array, sample_rate = load_audio_use_ffmpeg(audio_path, resample=False) + print("[INFO] Loaded audio for slicing via ffmpeg") + except Exception as exc: + yield f"❌ Failed to load audio for slicing: {exc}", "" + return + sliced_path, err = slice_audio_to_temp(audio_array, sample_rate, start_sec, end_sec) + if err: + yield f"❌ {err}", "" + return + audio_path = sliced_path + print(f"[INFO] Sliced audio written to temp file: {audio_path}") + elif audio_array is not None and sample_rate is not None: + # no slicing but microphone input: write to temp file + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + audio_path = temp_file.name + temp_file.close() + audio_data_int16 = (audio_array * 32768.0).astype(np.int16) + sf.write(audio_path, audio_data_int16, sample_rate, subtype='PCM_16') + print(f"[INFO] Microphone audio saved to temp file: {audio_path}") + + # Create streamer for real-time output + streamer = TextIteratorStreamer( + asr_model.processor.tokenizer, + skip_prompt=True, + skip_special_tokens=True + ) + + # Store result in a mutable container for the thread + result_container = {"result": None, "error": None} + + def run_transcription(): + try: + result_container["result"] = asr_model.transcribe( + audio_path=audio_path, + max_new_tokens=max_new_tokens, + temperature=temperature, + top_p=top_p, + do_sample=do_sample, + repetition_penalty=repetition_penalty, + context_info=context_info if context_info and context_info.strip() else None, + streamer=streamer + ) + except Exception as e: + result_container["error"] = str(e) + traceback.print_exc() + + # Start transcription in background thread + print("[INFO] Starting model transcription (streaming mode)") + start_time = time.time() + transcription_thread = threading.Thread(target=run_transcription) + transcription_thread.start() + + # Yield streaming output + generated_text = "" + token_count = 0 + for new_text in streamer: + generated_text += new_text + token_count += 1 + elapsed = time.time() - start_time + # Show streaming output with live stats, format for readability + formatted_text = generated_text.replace('},', '},\n') + streaming_output = f"--- 🔴 LIVE Streaming Output (tokens: {token_count}, time: {elapsed:.1f}s) ---\n{formatted_text}" + yield streaming_output, "

⏳ Generating transcription... Audio segments will appear after completion.
" + + # Wait for thread to complete + transcription_thread.join() + + if result_container["error"]: + yield f"❌ Error during transcription: {result_container['error']}", "" + return + + result = result_container["result"] + generation_time = time.time() - start_time + + # Get input token statistics + input_tokens = result.get('input_tokens', {}) + speech_tokens = input_tokens.get('speech', 0) + text_tokens = input_tokens.get('text', 0) + padding_tokens = input_tokens.get('padding', 0) + total_input = input_tokens.get('total', 0) + + # Format final raw output with input/output token stats + raw_output = f"--- ✅ Raw Output ---\n" + raw_output += f"📥 Input: {total_input} tokens (🎤 speech: {speech_tokens}, 📝 text: {text_tokens}, ⬜ pad: {padding_tokens})\n" + raw_output += f"📤 Output: {token_count} tokens | ⏱️ Time: {generation_time:.2f}s\n" + raw_output += f"---\n" + # Format raw text for better readability: add newline after each dict (},) + formatted_raw_text = result['raw_text'].replace('},', '},\n') + raw_output += formatted_raw_text + + # Debug: print raw output to console + print(f"[DEBUG] Raw model output:") + print(f"[DEBUG] {result['raw_text']}") + print(f"[DEBUG] Found {len(result['segments'])} segments") + + # Create audio segments with server-side encoding (low quality for minimal transfer) + # Using: 16kHz mono MP3 @ 32kbps = ~4KB per second of audio + audio_segments_html = "" + segments = result['segments'] + + if segments: + num_segments = len(segments) + print(f"[INFO] Creating per-segment audio clips ({num_segments} segments, 16kHz mono MP3 @ 32kbps)") + + # Extract all audio segments efficiently (load audio only once) + audio_segments = extract_audio_segments(audio_path, segments) + print("[INFO] Completed creating audio clips") + + # Calculate approximate total size + total_duration = sum( + (seg.get('end_time', 0) - seg.get('start_time', 0)) + for seg in segments + if isinstance(seg.get('start_time'), (int, float)) and isinstance(seg.get('end_time'), (int, float)) + ) + approx_size_kb = total_duration * 4 # ~4KB per second at 32kbps + + # Add CSS for theme-aware styling + theme_css = """ + + """ + + audio_segments_html = theme_css + audio_segments_html += f"
" + + # Add format info + format_info = "MP3 32kbps 16kHz mono" if HAS_PYDUB else "WAV 16kHz" + audio_segments_html += f"

🔊 Audio Segments ({num_segments} segments)" + audio_segments_html += f"📦 ~{approx_size_kb:.0f}KB ({format_info})

" + audio_segments_html += "

🎵 Click the play button to listen to each segment directly!

" + + for i, (label, audio_src, error_msg) in enumerate(audio_segments): + seg = segments[i] if i < len(segments) else {} + start_time = seg.get('start_time', 'N/A') + end_time = seg.get('end_time', 'N/A') + speaker_id = seg.get('speaker_id', 'N/A') + content = seg.get('text', '') + + # Format times nicely + start_str = f"{start_time:.2f}" if isinstance(start_time, (int, float)) else str(start_time) + end_str = f"{end_time:.2f}" if isinstance(end_time, (int, float)) else str(end_time) + + audio_segments_html += f""" +
+
+

Segment {i+1}

+
+ Time: [{start_str}s - {end_str}s] | + Speaker: {speaker_id} +
+
+ +
+ {content} +
+ """ + + if audio_src: + # Detect format from data URI + audio_type = 'audio/mp3' if 'audio/mp3' in audio_src else 'audio/wav' + audio_segments_html += f""" + + """ + elif error_msg: + audio_segments_html += f""" +
+ ❌ {error_msg} +
+ """ + else: + audio_segments_html += """ +
+ Audio playback unavailable for this segment +
+ """ + + audio_segments_html += "
" + + audio_segments_html += "
" + else: + audio_segments_html = """ + +
+

❌ No audio segments available.

+

This could happen if the model output doesn't contain valid time stamps.

+
+ """ + + # Final yield with complete results + yield raw_output, audio_segments_html + + except Exception as e: + print(f"Error during transcription: {e}") + print(traceback.format_exc()) + yield f"❌ Error during transcription: {str(e)}", "" + + +def create_gradio_interface(model_path: str, default_max_tokens: int = 8192, attn_implementation: str = "flash_attention_2"): + """Create and launch Gradio interface. + + Args: + model_path: Path to the model (HuggingFace format directory or model name) + default_max_tokens: Default value for max_new_tokens slider + attn_implementation: Attention implementation to use ('flash_attention_2', 'sdpa', 'eager') + """ + + # Initialize model at startup + device = "cuda" if torch.cuda.is_available() else "cpu" + model_status = initialize_model(model_path, device, attn_implementation) + print(model_status) + + # Exit if model loading failed + if model_status.startswith("❌"): + print("\n" + "="*80) + print("💥 FATAL ERROR: Model loading failed!") + print("="*80) + print("Cannot start demo without a valid model. Please check:") + print(" 1. Model path is correct") + print(" 2. Model files are not corrupted") + print(" 3. You have enough GPU memory") + print(" 4. CUDA is properly installed (if using GPU)") + print("="*80) + sys.exit(1) + + # Custom CSS for Stop button styling + custom_css = """ + #stop-btn { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important; + border: none !important; + color: white !important; + } + #stop-btn:hover { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important; + } + """ + + # Gradio 6.0+ moved theme/css to launch() + with gr.Blocks(title="VibeVoice ASR Demo") as demo: + gr.Markdown("# 🎙️ VibeVoice ASR Demo") + gr.Markdown("Upload audio files or record from microphone to get speech-to-text transcription with speaker diarization.") + gr.Markdown(f"**Model loaded from:** `{model_path}`") + + with gr.Row(): + with gr.Column(scale=1): + # Generation parameters + gr.Markdown("## ⚙️ Generation Parameters") + max_tokens_slider = gr.Slider( + minimum=4096, + maximum=65536, + value=default_max_tokens, + step=4096, + label="Max New Tokens" + ) + + # Sampling parameters + gr.Markdown("### 🎲 Sampling") + do_sample_checkbox = gr.Checkbox( + value=False, + label="Enable Sampling", + info="Enable random sampling instead of deterministic decoding" + ) + + with gr.Column(visible=False) as sampling_params: + temperature_slider = gr.Slider( + minimum=0.0, + maximum=2.0, + value=0.0, + step=0.1, + label="Temperature", + info="0 = greedy, higher = more random" + ) + top_p_slider = gr.Slider( + minimum=0.0, + maximum=1.0, + value=1.0, + step=0.05, + label="Top-p (Nucleus Sampling)", + info="1.0 = no filtering" + ) + + # Repetition penalty (works with both greedy and sampling) + repetition_penalty_slider = gr.Slider( + minimum=1.0, + maximum=1.2, + value=1.0, + step=0.01, + label="Repetition Penalty", + info="1.0 = no penalty, higher = less repetition (works with greedy & sampling)" + ) + + # Context information section + gr.Markdown("## 📋 Context Info (Optional)") + context_info_input = gr.Textbox( + label="Context Information", + placeholder="Enter hotwords, speaker names, topics, or other context to help transcription...\nExample:\nJohn Smith\nMachine Learning\nOpenAI", + lines=4, + max_lines=8, + interactive=True, + info="Provide context like proper nouns, technical terms, or speaker names to improve accuracy" + ) + + with gr.Column(scale=2): + # Audio input section + gr.Markdown("## 🎵 Audio Input") + audio_input = gr.Audio( + label="Upload Audio File or Record from Microphone", + sources=["upload", "microphone"], + type="filepath", + interactive=True, + buttons=["download"] + ) + + with gr.Accordion("📂 Advanced: Remote Path & Time Slicing", open=False): + audio_path_input = gr.Textbox( + label="Audio path (optional)", + placeholder="Enter remote audio file path", + lines=1 + ) + with gr.Row(): + start_time_input = gr.Textbox( + label="Start time", + placeholder="e.g., 0 or 00:00:00", + lines=1, + info="Leave empty to start from the beginning" + ) + end_time_input = gr.Textbox( + label="End time", + placeholder="e.g., 30.5 or 00:00:30.5", + lines=1, + info="Leave empty to use full length" + ) + + with gr.Row(): + transcribe_button = gr.Button("🎯 Transcribe", variant="primary", size="lg", scale=3) + stop_button = gr.Button("⏹️ Stop", variant="secondary", size="lg", scale=1, elem_id="stop-btn") + + # Results section + gr.Markdown("## 📝 Results") + + with gr.Tabs(): + with gr.TabItem("Raw Output"): + raw_output = gr.Textbox( + label="Raw Transcription Output", + lines=8, + max_lines=20, + interactive=False + ) + + with gr.TabItem("Audio Segments"): + audio_segments_output = gr.HTML( + label="Play individual segments to verify accuracy" + ) + + # Event handlers + do_sample_checkbox.change( + fn=lambda x: gr.update(visible=x), + inputs=[do_sample_checkbox], + outputs=[sampling_params] + ) + + def reset_stop_flag(): + """Reset stop flag before starting transcription.""" + global stop_generation_flag + stop_generation_flag = False + + def set_stop_flag(): + """Set stop flag to interrupt generation.""" + global stop_generation_flag + stop_generation_flag = True + return "⏹️ Stop requested..." + + transcribe_button.click( + fn=reset_stop_flag, + inputs=[], + outputs=[], + queue=False + ).then( + fn=transcribe_audio, + inputs=[ + audio_input, + audio_path_input, + start_time_input, + end_time_input, + max_tokens_slider, + temperature_slider, + top_p_slider, + do_sample_checkbox, + repetition_penalty_slider, + context_info_input + ], + outputs=[raw_output, audio_segments_output] + ) + + stop_button.click( + fn=set_stop_flag, + inputs=[], + outputs=[raw_output], + queue=False + ) + + # Add examples + gr.Markdown("## 📋 Instructions") + gr.Markdown(f""" + 1. **Upload Audio**: Use the audio component to upload a file or record from microphone + - **Supported formats**: {', '.join(sorted(set([ext.lower() for ext in COMMON_AUDIO_EXTS])))} + - Optionally set **Start/End time** (seconds or hh:mm:ss) to clip before transcription + 2. **Context Info (Optional)**: Provide context to improve transcription accuracy + - Add hotwords, proper nouns, speaker names, or technical terms + - One item per line or comma-separated + - Examples: "John Smith", "OpenAI", "machine learning" + 3. **Adjust Parameters**: Configure generation parameters as needed + 4. **Transcribe**: Click "Transcribe" to get results + 5. **Review Results**: + - **Raw Output**: View the model's original output + - **Audio Segments**: Play individual segments directly to verify accuracy + + **Audio Segments**: Each segment shows the time range, speaker ID, transcribed content, and an embedded audio player for immediate verification. + """) + + return demo, custom_css + + +def main(): + parser = argparse.ArgumentParser(description="VibeVoice ASR Gradio Demo") + parser.add_argument( + "--model_path", + type=str, + default="microsoft/VibeVoice-ASR", + help="Path to the model (HuggingFace format directory or model name)" + ) + parser.add_argument( + "--attn_implementation", + type=str, + default="flash_attention_2", + help="Attention implementation to use (default: flash_attention_2)" + ) + parser.add_argument( + "--max_new_tokens", + type=int, + default=32768, + help="Default max new tokens for generation (default: 32768)" + ) + parser.add_argument( + "--host", + type=str, + default="0.0.0.0", + help="Host to bind the server to" + ) + parser.add_argument( + "--port", + type=int, + default=7860, + help="Port to bind the server to" + ) + parser.add_argument( + "--share", + action="store_true", + help="Create a public link" + ) + + args = parser.parse_args() + + # Create and launch interface + demo, custom_css = create_gradio_interface( + model_path=args.model_path, + default_max_tokens=args.max_new_tokens, + attn_implementation=args.attn_implementation + ) + + print(f"🚀 Starting VibeVoice ASR Demo...") + print(f"📍 Server will be available at: http://{args.host}:{args.port}") + + # Gradio 6.0+ moved theme/css to launch() + launch_kwargs = { + "server_name": args.host, + "server_port": args.port, + "share": args.share, + "show_error": True, + "theme": gr.themes.Soft(), + "css": custom_css, + } + + # Enable queue for concurrent request handling + demo.queue(default_concurrency_limit=3) + demo.launch(**launch_kwargs) + + +if __name__ == "__main__": + main() diff --git a/demo/vibevoice_asr_inference_from_file.py b/demo/vibevoice_asr_inference_from_file.py new file mode 100644 index 0000000..e7d95d4 --- /dev/null +++ b/demo/vibevoice_asr_inference_from_file.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python +""" +VibeVoice ASR Batch Inference Demo Script + +This script supports batch inference for ASR model and compares results +between batch processing and single-sample processing. +""" + +import os +import sys +import torch +import numpy as np +from pathlib import Path +import argparse +import time +import json +import re +from typing import List, Dict, Any, Optional +from functools import wraps + +from vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration +from vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor + + +class VibeVoiceASRBatchInference: + """Batch inference wrapper for VibeVoice ASR model.""" + + def __init__( + self, + model_path: str, + device: str = "cuda", + dtype: torch.dtype = torch.bfloat16, + attn_implementation: str = "flash_attention_2" + ): + """ + Initialize the ASR batch inference pipeline. + + Args: + model_path: Path to the pretrained model + device: Device to run inference on + dtype: Data type for model weights + attn_implementation: Attention implementation to use ('flash_attention_2', 'sdpa', 'eager') + """ + print(f"Loading VibeVoice ASR model from {model_path}") + + # Load processor + self.processor = VibeVoiceASRProcessor.from_pretrained( + model_path, + language_model_pretrained_name="Qwen/Qwen2.5-7B" + ) + + # Load model with specified attention implementation + print(f"Using attention implementation: {attn_implementation}") + self.model = VibeVoiceASRForConditionalGeneration.from_pretrained( + model_path, + dtype=dtype, + device_map=device if device == "auto" else None, + attn_implementation=attn_implementation, + trust_remote_code=True + ) + + if device != "auto": + self.model = self.model.to(device) + + self.device = device if device != "auto" else next(self.model.parameters()).device + self.dtype = dtype + self.model.eval() + + print(f"Model loaded successfully on {self.device}") + + def _prepare_generation_config( + self, + max_new_tokens: int = 512, + temperature: float = 0.0, + top_p: float = 0.9, + do_sample: bool = True, + num_beams: int = 1, + ) -> dict: + """Prepare generation configuration.""" + config = { + "max_new_tokens": max_new_tokens, + "pad_token_id": self.processor.pad_id, + "eos_token_id": self.processor.tokenizer.eos_token_id, + } + + # Beam search vs sampling + if num_beams > 1: + config["num_beams"] = num_beams + config["do_sample"] = False # Beam search doesn't use sampling + else: + config["do_sample"] = do_sample + # Only set temperature and top_p when sampling is enabled + if do_sample: + config["temperature"] = temperature + config["top_p"] = top_p + + return config + + def transcribe_batch( + self, + audio_inputs: List, + max_new_tokens: int = 512, + temperature: float = 0.0, + top_p: float = 1.0, + do_sample: bool = True, + num_beams: int = 1, + ) -> List[Dict[str, Any]]: + """ + Transcribe multiple audio files/arrays in a single batch. + + Args: + audio_inputs: List of audio file paths or (array, sampling_rate) tuples + max_new_tokens: Maximum tokens to generate + temperature: Temperature for sampling + top_p: Top-p for nucleus sampling + do_sample: Whether to use sampling + + Returns: + List of transcription results + """ + if len(audio_inputs) == 0: + return [] + + batch_size = len(audio_inputs) + print(f"\nProcessing batch of {batch_size} audio(s)...") + + # Process all audio together + inputs = self.processor( + audio=audio_inputs, + sampling_rate=None, + return_tensors="pt", + padding=True, + add_generation_prompt=True + ) + + # Move to device + inputs = {k: v.to(self.device) if isinstance(v, torch.Tensor) else v + for k, v in inputs.items()} + + # Print batch info + print(f" Input IDs shape: {inputs['input_ids'].shape}") + print(f" Speech tensors shape: {inputs['speech_tensors'].shape}") + print(f" Attention mask shape: {inputs['attention_mask'].shape}") + + # Generate + generation_config = self._prepare_generation_config( + max_new_tokens=max_new_tokens, + temperature=temperature, + top_p=top_p, + do_sample=do_sample, + num_beams=num_beams, + ) + + start_time = time.time() + + with torch.no_grad(): + output_ids = self.model.generate( + **inputs, + **generation_config + ) + + generation_time = time.time() - start_time + + # Decode outputs for each sample in the batch + results = [] + input_length = inputs['input_ids'].shape[1] + + for i, audio_input in enumerate(audio_inputs): + # Get generated tokens for this sample (excluding input tokens) + generated_ids = output_ids[i, input_length:] + + # Remove padding tokens from the end + # Find the first eos_token or pad_token + eos_positions = (generated_ids == self.processor.tokenizer.eos_token_id).nonzero(as_tuple=True)[0] + if len(eos_positions) > 0: + generated_ids = generated_ids[:eos_positions[0] + 1] + + generated_text = self.processor.decode(generated_ids, skip_special_tokens=True) + + # Parse structured output + try: + transcription_segments = self.processor.post_process_transcription(generated_text) + except Exception as e: + print(f"Warning: Failed to parse structured output: {e}") + transcription_segments = [] + + # Get file name based on input type + if isinstance(audio_input, str): + file_name = audio_input + elif isinstance(audio_input, dict) and 'id' in audio_input: + file_name = audio_input['id'] + else: + file_name = f"audio_{i}" + + results.append({ + "file": file_name, + "raw_text": generated_text, + "segments": transcription_segments, + "generation_time": generation_time / batch_size, + }) + + print(f" Total generation time: {generation_time:.2f}s") + print(f" Average time per sample: {generation_time/batch_size:.2f}s") + + return results + + def transcribe_with_batching( + self, + audio_inputs: List, + batch_size: int = 4, + max_new_tokens: int = 512, + temperature: float = 0.0, + top_p: float = 1.0, + do_sample: bool = True, + num_beams: int = 1, + ) -> List[Dict[str, Any]]: + """ + Transcribe multiple audio files/arrays with automatic batching. + + Args: + audio_inputs: List of audio file paths or (array, sampling_rate) tuples + batch_size: Number of samples per batch + max_new_tokens: Maximum tokens to generate + temperature: Temperature for sampling + top_p: Top-p for nucleus sampling + do_sample: Whether to use sampling + + Returns: + List of transcription results + """ + all_results = [] + + # Process in batches + for i in range(0, len(audio_inputs), batch_size): + batch_inputs = audio_inputs[i:i + batch_size] + print(f"\n{'='*60}") + print(f"Processing batch {i//batch_size + 1}/{(len(audio_inputs) + batch_size - 1)//batch_size}") + + batch_results = self.transcribe_batch( + batch_inputs, + max_new_tokens=max_new_tokens, + temperature=temperature, + top_p=top_p, + do_sample=do_sample, + num_beams=num_beams, + ) + all_results.extend(batch_results) + + return all_results + + +def print_result(result: Dict[str, Any]): + """Pretty print a single transcription result.""" + print(f"\nFile: {result['file']}") + print(f"Generation Time: {result['generation_time']:.2f}s") + print(f"\n--- Raw Output ---") + print(result['raw_text'][:500] + "..." if len(result['raw_text']) > 500 else result['raw_text']) + + if result['segments']: + print(f"\n--- Structured Output ({len(result['segments'])} segments) ---") + for seg in result['segments'][:50]: # Show first 50 segments + print(f"[{seg.get('start_time', 'N/A')} - {seg.get('end_time', 'N/A')}] " + f"Speaker {seg.get('speaker_id', 'N/A')}: {seg.get('text', '')}...") + if len(result['segments']) > 50: + print(f" ... and {len(result['segments']) - 50} more segments") + + +def load_dataset_and_concatenate( + dataset_name: str, + split: str, + max_duration: float, + num_audios: int, + target_sr: int = 24000 +) -> Optional[List[np.ndarray]]: + """ + Load a HuggingFace dataset and concatenate audio samples into long audio chunks. + (Note, just for demo purpose, not for benchmark evaluation) + + Args: + dataset_name: HuggingFace dataset name (e.g., 'openslr/librispeech_asr') + split: Dataset split to use (e.g., 'test', 'test.other') + max_duration: Maximum duration in seconds for each concatenated audio + num_audios: Number of concatenated audios to create + target_sr: Target sample rate (default: 24000) + + Returns: + List of concatenated audio arrays, or None if loading fails + """ + try: + from datasets import load_dataset + import torchcodec # just for decode audio in datasets + except ImportError: + print("Please install it with: pip install datasets torchcodec") + return None + + print(f"\nLoading dataset: {dataset_name} (split: {split})") + print(f"Will create {num_audios} concatenated audio(s), each up to {max_duration:.1f}s ({max_duration/3600:.2f} hours)") + + try: + # Use streaming to avoid downloading the entire dataset + dataset = load_dataset(dataset_name, split=split, streaming=True) + print(f"Dataset loaded in streaming mode") + + concatenated_audios = [] # List of concatenated audio metadata + + # Create multiple concatenated audios based on num_audios + current_chunks = [] + current_duration = 0.0 + current_samples_used = 0 + sample_idx = 0 + + for sample in dataset: + if len(concatenated_audios) >= num_audios: + break + + if 'audio' not in sample: + continue + + audio_data = sample['audio'] + audio_array = audio_data['array'] + sr = audio_data['sampling_rate'] + + # Resample if needed + if sr != target_sr: + duration = len(audio_array) / sr + new_length = int(duration * target_sr) + audio_array = np.interp( + np.linspace(0, len(audio_array) - 1, new_length), + np.arange(len(audio_array)), + audio_array + ) + + chunk_duration = len(audio_array) / target_sr + + # Check if adding this chunk exceeds max_duration + if current_duration + chunk_duration > max_duration: + remaining_duration = max_duration - current_duration + if remaining_duration > 0.5: # Only add if > 0.5s remaining + samples_to_take = int(remaining_duration * target_sr) + current_chunks.append(audio_array[:samples_to_take]) + current_duration += remaining_duration + current_samples_used += 1 + + # Save current concatenated audio and start a new one + if current_chunks: + concatenated_audios.append({ + 'array': np.concatenate(current_chunks), + 'duration': current_duration, + 'samples_used': current_samples_used, + }) + print(f" Created audio {len(concatenated_audios)}: {current_duration:.1f}s from {current_samples_used} samples") + + # Reset for next concatenated audio + current_chunks = [] + current_duration = 0.0 + current_samples_used = 0 + + if len(concatenated_audios) >= num_audios: + break + + current_chunks.append(audio_array) + current_duration += chunk_duration + current_samples_used += 1 + + sample_idx += 1 + if sample_idx % 100 == 0: + print(f" Processed {sample_idx} samples...") + + # Don't forget the last batch if it has content + if current_chunks and len(concatenated_audios) < num_audios: + concatenated_audios.append({ + 'array': np.concatenate(current_chunks), + 'duration': current_duration, + 'samples_used': current_samples_used, + }) + print(f" Created audio {len(concatenated_audios)}: {current_duration:.1f}s from {current_samples_used} samples") + + if not concatenated_audios: + print("Warning: No audio samples found in dataset") + return None + + # Extract arrays and print summary + result = [a['array'] for a in concatenated_audios] + total_duration = sum(a['duration'] for a in concatenated_audios) + total_samples = sum(a['samples_used'] for a in concatenated_audios) + print(f"\nCreated {len(result)} concatenated audio(s), total {total_duration:.1f}s ({total_duration/60:.1f} min) from {total_samples} samples") + + return result + + except Exception as e: + print(f"Error loading dataset: {e}") + import traceback + traceback.print_exc() + return None + + +def main(): + parser = argparse.ArgumentParser(description="VibeVoice ASR Batch Inference Demo") + parser.add_argument( + "--model_path", + type=str, + default="", + help="Path to the model checkpoint" + ) + parser.add_argument( + "--audio_files", + type=str, + nargs='+', + required=False, + help="Paths to audio files for transcription" + ) + parser.add_argument( + "--audio_dir", + type=str, + required=False, + help="Directory containing audio files for batch transcription" + ) + parser.add_argument( + "--dataset", + type=str, + required=False, + help="HuggingFace dataset name (e.g., 'openslr/librispeech_asr')" + ) + parser.add_argument( + "--split", + type=str, + default="test", + help="Dataset split to use (e.g., 'test', 'test.other', 'test.clean')" + ) + parser.add_argument( + "--max_duration", + type=float, + default=3600.0, + help="Maximum duration in seconds for concatenated dataset audio (default: 3600 = 1 hour)" + ) + parser.add_argument( + "--batch_size", + type=int, + default=2, + help="Batch size for processing multiple files" + ) + parser.add_argument( + "--device", + type=str, + default="cuda" if torch.cuda.is_available() else "cpu", + choices=["cuda", "cpu", "auto"], + help="Device to run inference on" + ) + parser.add_argument( + "--max_new_tokens", + type=int, + default=32768, + help="Maximum number of tokens to generate" + ) + parser.add_argument( + "--temperature", + type=float, + default=0.0, + help="Temperature for sampling (0 = greedy decoding)" + ) + parser.add_argument( + "--top_p", + type=float, + default=1.0, + help="Top-p for nucleus sampling" + ) + parser.add_argument( + "--num_beams", + type=int, + default=1, + help="Number of beams for beam search. Use 1 for greedy/sampling" + ) + parser.add_argument( + "--attn_implementation", + type=str, + default="flash_attention_2", + help="Attention implementation to use (default: flash_attention_2)" + ) + + args = parser.parse_args() + + # Collect audio files + audio_files = [] + concatenated_audio = None # For storing concatenated dataset audio + + if args.audio_files: + audio_files.extend(args.audio_files) + + if args.audio_dir: + import glob + for ext in ["*.wav", "*.mp3", "*.flac", "*.mp4", "*.m4a", "*.webm"]: + audio_files.extend(glob.glob(os.path.join(args.audio_dir, ext))) + + if args.dataset: + concatenated_audio = load_dataset_and_concatenate( + dataset_name=args.dataset, + split=args.split, + max_duration=args.max_duration, + num_audios=args.batch_size, + ) + if concatenated_audio is None: + return + + if len(audio_files) == 0 and concatenated_audio is None: + print("No audio files provided. Please specify --audio_files, --audio_dir, or --dataset.") + return + + if audio_files: + print(f"\nAudio files to process ({len(audio_files)}):") + for f in audio_files: + print(f" - {f}") + + if concatenated_audio: + print(f"\nConcatenated dataset audios: {len(concatenated_audio)} audio(s)") + + # Initialize model + asr = VibeVoiceASRBatchInference( + model_path=args.model_path, + device=args.device, + dtype=torch.bfloat16 if args.device != "cpu" else torch.float32, + attn_implementation=args.attn_implementation + ) + + # If temperature is 0, use greedy decoding (no sampling) + do_sample = args.temperature > 0 + + # Combine all audio inputs + all_audio_inputs = audio_files + (concatenated_audio or []) + + print("\n" + "="*80) + print(f"Processing {len(all_audio_inputs)} audio(s)") + print("="*80) + + all_results = asr.transcribe_with_batching( + all_audio_inputs, + batch_size=args.batch_size, + max_new_tokens=args.max_new_tokens, + temperature=args.temperature, + top_p=args.top_p, + do_sample=do_sample, + num_beams=args.num_beams, + ) + + # Print results + print("\n" + "="*80) + print("Results") + print("="*80) + for result in all_results: + print("\n" + "-"*60) + print_result(result) + + +if __name__ == "__main__": + main() diff --git a/docs/vibevoice-asr.md b/docs/vibevoice-asr.md new file mode 100644 index 0000000..5b2b130 --- /dev/null +++ b/docs/vibevoice-asr.md @@ -0,0 +1,62 @@ +# VibeVoice-ASR: Long-Form Rich Transcription with User Prompts + +**VibeVoice-ASR** is the latest addition to the **VibeVoice** family. While the original VibeVoice / VibeVoice-Realtime focused on expressive TTS, **VibeVoice-ASR** focuses on understanding long-form speech with high precision and rich metadata. + +It is a unified speech-to-text model designed to handle **1-hour long-form audio** in a single pass, generating structured transcriptions containing **Who (Speaker), When (Timestamps), and What (Content)**, with support for **User-Customized Context**. + +## 🔥 Key Features + +- **🕒 60-min Single-Pass Processing**: + Unlike conventional ASR models that slice audio into short chunks (often losing global context), VibeVoice ASR accepts up to **60 minutes** of continuous audio input within 64K length. This ensures consistent speaker tracking and semantic coherence across the entire hour. + +- **👤 Optional Context Injection**: + Users can provide customized context (e.g., specific names, technical terms, or background info) to guide the recognition process, significantly improving accuracy on domain-specific content. + +- **📝 Rich Transcription (Who, When, What)**: + The model performs ASR, Diarization, and Timestamping simultaneously. The output is a structured sequence indicating *who* said *what* at *which time*. + +## 🏗️ Model Architecture + +

+ VibeVoice ASR Architecture +

+ +## Installation +We recommend to use NVIDIA Deep Learning Container to manage the CUDA environment. + +1. Launch docker +```bash +# NVIDIA PyTorch Container 24.07 ~ 25.12 verified. +# Previous versions are also compatible. +sudo docker run --privileged --net=host --ipc=host --ulimit memlock=-1:-1 --ulimit stack=-1:-1 --gpus all --rm -it nvcr.io/nvidia/pytorch:25.12-py3 + +## If flash attention is not included in your docker environment, you need to install it manually +## Refer to https://github.com/Dao-AILab/flash-attention for installation instructions +# pip install flash-attn --no-build-isolation +``` + +2. Install from github +```bash +git clone https://github.com/microsoft/VibeVoice.git +cd VibeVoice +pip install -e .[asr] +``` + +## Usages + +### Usage 1: Launch Gradio demo +```bash +apt update && apt install ffmpeg -y # for demo + +python demo/vibevoice_asr_gradio_demo.py --model_path microsoft/VibeVoice-ASR --share +``` + +### Usage 2: Inference from files directly +```bash +python demo/vibevoice_asr_inference_from_file.py --model_path microsoft/VibeVoice-ASR --audio_files [add a audio path here] +``` + + +## 📄 License + +This project is licensed under the [MIT License](../LICENSE). diff --git a/pyproject.toml b/pyproject.toml index a36e9cd..6dc69b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "vibevoice" -version = "0.0.1" +version = "1.0.0" authors = [ - { name="vibevoice team", email="vibepod@microsoft.com" }, + { name="vibevoice team", email="VibeVoice@microsoft.com" }, ] -description = "A model for speech generation with an AR + diffusion architecture." +description = "Open-Source Frontier Voice AI." readme = "README.md" requires-python = ">=3.9" classifiers = [ @@ -18,8 +18,7 @@ classifiers = [ ] dependencies = [ "torch", - "accelerate==1.6.0", - "transformers==4.51.3", # we develop this project on transformers==4.51.3, later version may not be compatible + "accelerate", "llvmlite>=0.40.0", "numba>=0.57.0", "diffusers", @@ -30,12 +29,21 @@ dependencies = [ "ml-collections", "absl-py", "gradio", - "av", - "aiortc", - "uvicorn[standard]", - "fastapi" ] +[project.optional-dependencies] +tts = [ + "transformers==4.51.3", # we develop this project on transformers==4.51.3, later version may not be compatible + "av", + "aiortc", + "uvicorn[standard]", + "fastapi" +] + +asr = [ + "transformers>=4.51.3", # the versions after 4.51.3 are all support + "pydub" # for visualization +] [project.urls] "Homepage" = "https://github.com/microsoft/VibeVoice" diff --git a/vibevoice/modular/configuration_vibevoice.py b/vibevoice/modular/configuration_vibevoice.py index fcffcb9..02b2751 100644 --- a/vibevoice/modular/configuration_vibevoice.py +++ b/vibevoice/modular/configuration_vibevoice.py @@ -240,9 +240,112 @@ class VibeVoiceConfig(PretrainedConfig): super().__init__(**kwargs) +class VibeVoiceASRConfig(PretrainedConfig): + model_type = "vibevoice" + is_composition = True + sub_configs = { + "acoustic_tokenizer_config": VibeVoiceAcousticTokenizerConfig, + "semantic_tokenizer_config": VibeVoiceSemanticTokenizerConfig, + "decoder_config": Qwen2Config, + } + # keys_to_ignore_at_inference = ["past_key_values"] + # Default tensor parallel plan for base model `Qwen2` + base_model_tp_plan = { + "layers.*.self_attn.q_proj": "colwise", + "layers.*.self_attn.k_proj": "colwise", + "layers.*.self_attn.v_proj": "colwise", + "layers.*.self_attn.o_proj": "rowwise", + "layers.*.mlp.gate_proj": "colwise", + "layers.*.mlp.up_proj": "colwise", + "layers.*.mlp.down_proj": "rowwise", + } + + def __init__( + self, + acoustic_tokenizer_config=None, + semantic_tokenizer_config=None, + decoder_config=None, + **kwargs + ): + + # kwargs["_attn_implementation"] = "flash_attention_2" + kwargs["_attn_implementation_autoset"] = False + + if acoustic_tokenizer_config is None: + self.acoustic_tokenizer_config = self.sub_configs["acoustic_tokenizer_config"]() + elif isinstance(acoustic_tokenizer_config, dict): + acoustic_tokenizer_config["model_type"] = "vibevoice_acoustic_tokenizer" + self.acoustic_tokenizer_config = self.sub_configs["acoustic_tokenizer_config"](**acoustic_tokenizer_config) + elif isinstance(acoustic_tokenizer_config, VibeVoiceAcousticTokenizerConfig): + # If an instance of the config class is provided + self.acoustic_tokenizer_config = acoustic_tokenizer_config + + if semantic_tokenizer_config is None: + self.semantic_tokenizer_config = self.sub_configs["semantic_tokenizer_config"]() + elif isinstance(semantic_tokenizer_config, dict): + semantic_tokenizer_config["model_type"] = "vibevoice_semantic_tokenizer" + self.semantic_tokenizer_config = self.sub_configs["semantic_tokenizer_config"](**semantic_tokenizer_config) + elif isinstance(semantic_tokenizer_config, VibeVoiceSemanticTokenizerConfig): + # If an instance of the config class is provided + self.semantic_tokenizer_config = semantic_tokenizer_config + + if decoder_config is None: + self.decoder_config = self.sub_configs["decoder_config"]() + elif isinstance(decoder_config, dict): + # If a dictionary is provided, instantiate the config class with it + # self.decoder_config = self.sub_configs["decoder_config"](**decoder_config) + if decoder_config.get("model_type", '') == "qwen2": + self.decoder_config = Qwen2Config(**decoder_config) + else: + raise ValueError(f"Unsupported decoder model type: {decoder_config.get('model_type', '')}") + elif isinstance(decoder_config, Qwen2Config): + # If an instance of the config class is provided + self.decoder_config = decoder_config + + # other parameters + self.acoustic_vae_dim = getattr(self.acoustic_tokenizer_config, 'vae_dim', 64) + self.semantic_vae_dim = getattr(self.semantic_tokenizer_config, 'vae_dim', 128) + + super().__init__(**kwargs) + + def get_text_config(self, decoder: bool = False): + """Return the text (decoder) config for generation.""" + return self.decoder_config + + @property + def vocab_size(self): + """Return vocab_size from decoder config for generation compatibility.""" + return self.decoder_config.vocab_size + + @property + def num_attention_heads(self): + """Return num_attention_heads from decoder config for Ulysses SP compatibility.""" + return self.decoder_config.num_attention_heads + + @property + def num_key_value_heads(self): + """Return num_key_value_heads from decoder config for Ulysses SP compatibility.""" + return self.decoder_config.num_key_value_heads + + @property + def hidden_size(self): + """Return hidden_size from decoder config for model compatibility.""" + return self.decoder_config.hidden_size + + @property + def num_hidden_layers(self): + """Return num_hidden_layers from decoder config for Ulysses SP compatibility.""" + return self.decoder_config.num_hidden_layers + + @property + def head_dim(self): + """Return head_dim from decoder config for Ulysses SP compatibility.""" + return getattr(self.decoder_config, 'head_dim', self.hidden_size // self.num_attention_heads) + __all__ = [ "VibeVoiceAcousticTokenizerConfig", "VibeVoiceSemanticTokenizerConfig", "VibeVoiceDiffusionHeadConfig", - "VibeVoiceConfig" + "VibeVoiceConfig", + "VibeVoiceASRConfig" ] \ No newline at end of file diff --git a/vibevoice/modular/modeling_vibevoice.py b/vibevoice/modular/modeling_vibevoice.py new file mode 100644 index 0000000..a4ecbab --- /dev/null +++ b/vibevoice/modular/modeling_vibevoice.py @@ -0,0 +1,496 @@ +# copied from https://github.com/vibevoice-community/VibeVoice/blob/main/vibevoice/modular/modeling_vibevoice.py +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, Union, Callable +from tqdm import tqdm +import torch +import torch.nn as nn +import torch.nn.functional as F +import torch.distributed as dist + +from transformers.models.auto import AutoModel, AutoModelForCausalLM + +from transformers.activations import ACT2FN +from transformers.modeling_outputs import CausalLMOutput, BaseModelOutputWithPast, ModelOutput +from transformers.models.llama.modeling_llama import LlamaRMSNorm +from transformers import modeling_utils +from transformers.modeling_utils import PreTrainedModel +from transformers.modeling_flash_attention_utils import FlashAttentionKwargs +from transformers.utils import logging + + +from .modular_vibevoice_tokenizer import VibeVoiceTokenizerStreamingCache, VibeVoiceAcousticTokenizerModel, VibeVoiceSemanticTokenizerModel +from .modular_vibevoice_diffusion_head import VibeVoiceDiffusionHead +from vibevoice.schedule.dpm_solver import DPMSolverMultistepScheduler + +from .configuration_vibevoice import VibeVoiceConfig + + +logger = logging.get_logger(__name__) + +if not hasattr(modeling_utils, "ALL_PARALLEL_STYLES") or modeling_utils.ALL_PARALLEL_STYLES is None: + modeling_utils.ALL_PARALLEL_STYLES = ["tp", "none", "colwise", "rowwise"] + +@dataclass +class VibeVoiceCausalLMOutputWithPast(ModelOutput): + loss: Optional[torch.FloatTensor] = None + diffusion_loss: Optional[torch.FloatTensor] = None + speech_token_num: Optional[int] = None + logits: torch.FloatTensor = None + past_key_values: Optional[Tuple[Tuple[torch.FloatTensor]]] = None + hidden_states: Optional[Tuple[torch.FloatTensor, ...]] = None + attentions: Optional[Tuple[torch.FloatTensor, ...]] = None + + +@dataclass +class VibeVoiceGenerationOutput(ModelOutput): + """ + Output type for VibeVoice generation. + + Args: + sequences (`torch.LongTensor` of shape `(batch_size, sequence_length)`): + The generated sequences. + speech_outputs (`List[torch.FloatTensor]`, *optional*): + List of generated speech waveforms or latents for each speech segment. + """ + sequences: torch.LongTensor = None + speech_outputs: Optional[List[torch.FloatTensor]] = None + + +class SpeechConnector(nn.Module): + def __init__(self, input_dim, output_dim): + super().__init__() + self.fc1 = nn.Linear(input_dim, output_dim) + self.norm = LlamaRMSNorm(output_dim, eps=1e-6) + self.fc2 = nn.Linear(output_dim, output_dim) + + def forward(self, features, **kwargs): + x = self.fc1(features) + x = self.norm(x) + x = self.fc2(x) + return x + + +# @auto_docstring +class VibeVoicePreTrainedModel(PreTrainedModel): + config_class = VibeVoiceConfig + base_model_prefix = "model" + supports_gradient_checkpointing = True + _skip_keys_device_placement = "past_key_values" + _supports_cache_class = True + _supports_flash_attn_2 = True + _supports_sdpa = True + _supports_quantized_cache = True + _supports_static_cache = True + _supports_attention_backend = True + + def _init_weights(self, module): + if isinstance(module, VibeVoiceDiffusionHead): + module.initialize_weights() + return + + # Use the language model's initializer_range if available + if hasattr(self.config, 'language_model_config') and hasattr(self.config.language_model_config, 'initializer_range'): + std = self.config.language_model_config.initializer_range + elif hasattr(self.config, 'decoder_config') and hasattr(self.config.decoder_config, 'initializer_range'): + std = self.config.decoder_config.initializer_range + else: + std = 0.02 # Default value + + if isinstance(module, nn.Linear): + module.weight.data.normal_(mean=0.0, std=std) + if module.bias is not None: + module.bias.data.zero_() + elif isinstance(module, nn.LayerNorm): + module.weight.data.fill_(1.0) + module.bias.data.zero_() + +# @auto_docstring +class VibeVoiceModel(VibeVoicePreTrainedModel): + def __init__(self, config): + super().__init__(config) + + if hasattr(config, 'torch_dtype') and config.torch_dtype is not None: + if isinstance(config.torch_dtype, str): + dtype = getattr(torch, config.torch_dtype) + else: + dtype = config.torch_dtype + else: + dtype = torch.float32 + + # Initialize Qwen2 model for language modeling + lm_config = config.decoder_config + self.language_model = AutoModel.from_config(lm_config) + + # Initialize speech components if needed + self.acoustic_tokenizer = AutoModel.from_config(config.acoustic_tokenizer_config).to(dtype) + self.semantic_tokenizer = AutoModel.from_config(config.semantic_tokenizer_config).to(dtype) + + self.acoustic_connector = SpeechConnector(config.acoustic_vae_dim, lm_config.hidden_size).to(dtype) + self.semantic_connector = SpeechConnector(config.semantic_vae_dim, lm_config.hidden_size).to(dtype) + + # Register scaling factors as buffers - use 1D tensors for FSDP compatibility + self.register_buffer('speech_scaling_factor', torch.tensor(float('nan'))) + self.register_buffer('speech_bias_factor', torch.tensor(float('nan'))) + + # Initialize prediction head for speech generation + self.prediction_head = AutoModel.from_config(config.diffusion_head_config).to(dtype) + + # Initialize noise scheduler + self.noise_scheduler = DPMSolverMultistepScheduler( + num_train_timesteps=config.diffusion_head_config.ddpm_num_steps, + beta_schedule=config.diffusion_head_config.ddpm_beta_schedule, + prediction_type=config.diffusion_head_config.prediction_type + ) + + def get_input_embeddings(self): + if hasattr(self.language_model, 'embed_tokens'): + # If the language model has an embed_tokens attribute, return it + return self.language_model.embed_tokens + + for name, attr in self.language_model.fullmap.items(): # parallel by nnscaler, the name is changed + if attr.orig_name == 'embed_tokens.weight': + return getattr(self.language_model, name) + assert False, 'should not arrive here' + + def set_input_embeddings(self, value): + self.language_model.embed_tokens = value + + def set_speech_tokenizers(self, acoustic_tokenizer=None, semantic_tokenizer=None): + """Set the speech tokenizers used for encoding and decoding speech.""" + self.acoustic_tokenizer = acoustic_tokenizer + self.semantic_tokenizer = semantic_tokenizer + + # Reset the encoder to evaluation mode + if self.acoustic_tokenizer is not None: + self.acoustic_tokenizer.eval() + + if self.semantic_tokenizer is not None: + self.semantic_tokenizer.eval() + + def forward( + self, + input_ids: torch.LongTensor = None, + attention_mask: Optional[torch.Tensor] = None, + position_ids: Optional[torch.LongTensor] = None, + past_key_values: Optional[Tuple[Tuple[torch.FloatTensor]]] = None, + inputs_embeds: Optional[torch.FloatTensor] = None, + use_cache: Optional[bool] = None, + output_attentions: Optional[bool] = None, + output_hidden_states: Optional[bool] = None, + return_dict: Optional[bool] = None, + cache_position: Optional[torch.LongTensor] = None, + **kwargs, + ) -> Union[Tuple, BaseModelOutputWithPast]: + + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + # Forward through language model + outputs = self.language_model( + input_ids=input_ids, + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + inputs_embeds=inputs_embeds, + use_cache=use_cache, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + cache_position=cache_position, + **kwargs, + ) + + if not return_dict: + return outputs + + return BaseModelOutputWithPast( + last_hidden_state=outputs.last_hidden_state, + past_key_values=outputs.past_key_values, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + ) + + +class VibeVoiceForConditionalGeneration(VibeVoicePreTrainedModel): + _tied_weights_keys = ["lm_head.weight"] + _tp_plan = {"lm_head": "colwise_rep"} + + def __init__(self, config): + super().__init__(config) + self.model = VibeVoiceModel(config) + self.vocab_size = config.decoder_config.vocab_size + self.lm_head = nn.Linear(config.decoder_config.hidden_size, self.vocab_size, bias=False) + + self.post_init() + + def get_input_embeddings(self): + return self.model.get_input_embeddings() + + def set_input_embeddings(self, value): + self.model.set_input_embeddings(value) + + def get_output_embeddings(self): + return self.lm_head + + def set_decoder(self, decoder): + self.model.language_model = decoder + + def get_decoder(self): + return self.model.language_model + + def tie_weights(self): + """ + Tie the weights between the input embeddings and the output embeddings. + """ + if getattr(self.config.decoder_config, 'tie_word_embeddings', False): + # The standard PreTrainedModel method will handle the tying. + # It typically does a simple parameter object assignment, which is + # CORRECT to do BEFORE FSDP wraps the model. + output_embeddings = self.get_output_embeddings() + input_embeddings = self.get_input_embeddings() + if hasattr(input_embeddings, 'weight'): + output_embeddings.weight = input_embeddings.weight + else: + # maybe returned input_embeddings a tensor directly + output_embeddings.weight = input_embeddings + + if getattr(output_embeddings, "bias", None) is not None: + output_embeddings.bias.data = nn.functional.pad( + output_embeddings.bias.data, + (0, output_embeddings.weight.shape[0] - output_embeddings.bias.shape[0]), + "constant", + 0, + ) + print("Tied input and output embeddings using standard assignment.") + else: + print("tie_word_embeddings is False, not tying weights.") + + # Also, ensure set_output_embeddings is safe, though your implementation looks okay. + # The key is to avoid calling it after accelerator.prepare(). + def set_output_embeddings(self, new_embeddings): + # Your current implementation using data.copy_ is good practice, + # but the best way is to not call this after prepare(). + self.lm_head = new_embeddings + + def forward_speech_features( + self, + speech_tensors=None, + speech_masks=None, + speech_type="audio", + return_unmask=False + ): + if speech_tensors is None: + # Use config to get vae_dim instead of non-existent self.args + vae_dim = self.config.acoustic_tokenizer_config.vae_dim + audio_features = torch.zeros(1, 1, vae_dim).to(self.get_input_embeddings().weight) + connect_features = self.model.acoustic_connector(audio_features) + return audio_features, connect_features + else: + with torch.no_grad(): + if speech_type == "audio": + with torch.no_grad(): + frames = self.model.acoustic_tokenizer.encode(speech_tensors.unsqueeze(1))[0][0] + audio_tokens = frames.sample(self.model.acoustic_tokenizer.std_dist_type)[0] + + elif speech_type == "vae": + # Use config to get vae_dim instead of non-existent self.args + vae_dim = self.config.acoustic_tokenizer_config.vae_dim + speech_mode = speech_tensors.reshape(speech_tensors.size(0), -1, vae_dim) + + # gaussian sample from the speech_mode + batch_size = speech_mode.size(0) + value = self.model.acoustic_tokenizer.fix_std / 0.8 + std = torch.randn(batch_size, dtype=speech_mode.dtype, device=speech_mode.device) * value + std = std.view(-1, *[1] * (speech_mode.dim() - 1)) + audio_tokens = speech_mode + std * torch.randn(speech_mode.shape).to(speech_mode) + else: + raise NotImplementedError(f"Speech type {speech_type} not implemented") + + if torch.isnan(self.model.speech_scaling_factor) or torch.isnan(self.model.speech_bias_factor): + scaling_factor = 1. / audio_tokens[speech_masks].flatten().std() + bias_factor = -audio_tokens[speech_masks].flatten().mean() + + # Only use distributed operations if the process group is initialized + if dist.is_available() and dist.is_initialized(): + dist.all_reduce(scaling_factor, op=dist.ReduceOp.SUM) + dist.all_reduce(bias_factor, op=dist.ReduceOp.SUM) + world_size = dist.get_world_size() + self.model.speech_scaling_factor.copy_(scaling_factor / world_size) + self.model.speech_bias_factor.copy_(bias_factor / world_size) + print(f"Speech scaling factor (distributed): {self.model.speech_scaling_factor}, bias factor: {self.model.speech_bias_factor}", flush=True) + else: + # Single process case + self.model.speech_scaling_factor.copy_(scaling_factor) + self.model.speech_bias_factor.copy_(bias_factor) + print(f"Speech scaling factor (single process): {self.model.speech_scaling_factor}, bias factor: {self.model.speech_bias_factor}", flush=True) + + audio_features = (audio_tokens + self.model.speech_bias_factor) * self.model.speech_scaling_factor + + connect_features = self.model.acoustic_connector(audio_features) + if return_unmask: + return audio_features, connect_features + return audio_features[speech_masks], connect_features[speech_masks] + + def forward( + self, + input_ids: torch.LongTensor = None, + attention_mask: Optional[torch.Tensor] = None, + position_ids: Optional[torch.LongTensor] = None, + past_key_values: Optional[List[torch.FloatTensor]] = None, + inputs_embeds: Optional[torch.FloatTensor] = None, + labels: Optional[torch.LongTensor] = None, + use_cache: Optional[bool] = False, + output_attentions: Optional[bool] = None, + output_hidden_states: Optional[bool] = None, + return_dict: Optional[bool] = None, + cache_position: Optional[torch.LongTensor] = None, + # New arguments for speech processing and loss calculation + speech_tensors: Optional[torch.FloatTensor] = None, + speech_masks: Optional[torch.BoolTensor] = None, + speeches_loss_input: Optional[torch.FloatTensor] = None, + speech_semantic_tensors: Optional[torch.FloatTensor] = None, + acoustic_input_mask: Optional[torch.BoolTensor] = None, + acoustic_loss_mask: Optional[torch.BoolTensor] = None, + ddpm_batch_mul: int = 1, + **kwargs: Optional[Dict[str, Union[torch.Tensor, str]]], + ) -> Union[Tuple, VibeVoiceCausalLMOutputWithPast]: + + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + x = self.get_input_embeddings()(input_ids) + + semantic_speech_all_connect_features = self.model.semantic_connector(speech_semantic_tensors) + if speeches_loss_input is not None: + # only part audio need diffuse + speech_all_features, speech_all_connect_features = self.forward_speech_features( + speech_tensors=speech_tensors.type_as(x) if speech_tensors is not None else None, + speech_masks=speech_masks, + speech_type=kwargs.get("speech_type", "audio"), + return_unmask=True + ) + if speech_tensors is not None: + if semantic_speech_all_connect_features is not None: + x[acoustic_input_mask] = ( + speech_all_connect_features[speech_masks] + + semantic_speech_all_connect_features[speech_masks] + ) + else: + x[acoustic_input_mask] = speech_all_connect_features[speech_masks] + + # Select only the target segments' latents for diffusion loss. + # Both masks are [num_segments, max_latent_len]; using 2D mask on [B,T,D] selects [N_true, D]. + target_latent_mask = speeches_loss_input & speech_masks + speech_features = speech_all_features[target_latent_mask] + speech_connect_features = speech_all_connect_features[target_latent_mask] + else: + speech_features, speech_connect_features = self.forward_speech_features( + speech_tensors=speech_tensors.type_as(x) if speech_tensors is not None else None, + speech_masks=speech_masks, + speech_type=kwargs.get("speech_type", "audio"), + ) + if speech_tensors is not None: + x[acoustic_input_mask] = speech_connect_features + + outputs = self.model( + input_ids=None, + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + inputs_embeds=x, + use_cache=use_cache, + output_attentions=output_attentions, + output_hidden_states=False, + return_dict=return_dict, + cache_position=cache_position, + ) + + hidden_states = outputs.last_hidden_state + logits = self.lm_head(hidden_states) + # logits = logits.float() + + loss = None + if labels is not None: + # The custom CE loss with masking is calculated in the training script. + # We leave the standard loss calculation here as None. + pass + + # --- Diffusion Loss Calculation --- + diffusion_loss = None + # This block is executed only if we are in a context that involves speech. + if speech_tensors is not None and acoustic_loss_mask.sum().item() > 0: + condition_features = hidden_states[acoustic_loss_mask] + + speech_len, latent_size = speech_features.shape + + noise = torch.randn( + (speech_len * ddpm_batch_mul, latent_size), + device=hidden_states.device, + dtype=hidden_states.dtype + ) + + timesteps = torch.multinomial( + torch.ones(self.config.diffusion_head_config.ddpm_num_steps), + speech_len * ddpm_batch_mul, + replacement=True, + ).to(hidden_states.device) + + speech_features_repeated = speech_features.repeat_interleave(ddpm_batch_mul, dim=0) + condition_features_repeated = condition_features.repeat_interleave(ddpm_batch_mul, dim=0) + + noisy_speech_features = self.model.noise_scheduler.add_noise( + speech_features_repeated, noise, timesteps + ) + + model_output = self.model.prediction_head( + noisy_speech_features, + timesteps.type_as(x), + condition_features_repeated + ) + + prediction_type = self.config.diffusion_head_config.prediction_type + if prediction_type == "epsilon": + target_for_loss = noise + elif prediction_type == "v_prediction": + target_for_loss = self.model.noise_scheduler.get_velocity( + speech_features_repeated, noise, timesteps + ) + else: + raise NotImplementedError(f"Prediction type {prediction_type} not implemented") + + diffusion_loss = F.mse_loss(model_output.float(), target_for_loss.float(), reduction='sum') + if latent_size > 0 and ddpm_batch_mul > 0: + diffusion_loss = diffusion_loss / latent_size / ddpm_batch_mul + else: + diffusion_loss = torch.tensor(0.0, device=diffusion_loss.device) + + else: + # Dummy loss for DDP to work when there are no speech samples in a batch, + # but we are in a speech context. + diffusion_loss = sum(p.sum() for p in self.model.prediction_head.parameters()) * 0.0 + diffusion_loss += sum(p.sum() for p in self.model.acoustic_connector.parameters()) * 0.0 + diffusion_loss += sum(p.sum() for p in self.model.semantic_connector.parameters()) * 0.0 + # --- End Diffusion Loss Calculation --- + + if not return_dict: + output = (logits, speech_len) + outputs.to_tuple()[1:] + return (loss, diffusion_loss) + output + + return VibeVoiceCausalLMOutputWithPast( + loss=loss, + diffusion_loss=diffusion_loss, + speech_token_num=speech_len if speech_tensors is not None else 0, + logits=logits, + past_key_values=outputs.past_key_values, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + ) + +AutoModel.register(VibeVoiceConfig, VibeVoiceModel) +AutoModelForCausalLM.register(VibeVoiceConfig, VibeVoiceForConditionalGeneration) + +__all__ = [ + "VibeVoiceModel", + "VibeVoicePreTrainedModel", + "VibeVoiceForConditionalGeneration", + "VibeVoiceCausalLMOutputWithPast", + "VibeVoiceGenerationOutput", +] \ No newline at end of file diff --git a/vibevoice/modular/modeling_vibevoice_asr.py b/vibevoice/modular/modeling_vibevoice_asr.py new file mode 100644 index 0000000..706bf00 --- /dev/null +++ b/vibevoice/modular/modeling_vibevoice_asr.py @@ -0,0 +1,520 @@ +from typing import List, Optional, Tuple, Union +import torch +import torch.nn as nn + +from transformers.models.auto import AutoModel, AutoModelForCausalLM + +from transformers.modeling_outputs import CausalLMOutput, BaseModelOutputWithPast +from transformers import modeling_utils +from transformers.modeling_utils import PreTrainedModel +from transformers.utils import logging +from transformers.generation import GenerationMixin + +from .modular_vibevoice_tokenizer import ( + VibeVoiceTokenizerStreamingCache, + VibeVoiceTokenizerEncoderOutput +) + +from .configuration_vibevoice import VibeVoiceASRConfig +from .modeling_vibevoice import ( + VibeVoiceCausalLMOutputWithPast, + SpeechConnector +) + +logger = logging.get_logger(__name__) + +if not hasattr(modeling_utils, "ALL_PARALLEL_STYLES") or modeling_utils.ALL_PARALLEL_STYLES is None: + modeling_utils.ALL_PARALLEL_STYLES = ["tp", "none", "colwise", "rowwise"] + +# @auto_docstring +class VibeVoiceASRPreTrainedModel(PreTrainedModel): + config_class = VibeVoiceASRConfig + base_model_prefix = "model" + supports_gradient_checkpointing = True + _skip_keys_device_placement = "past_key_values" + _supports_cache_class = True + _supports_flash_attn = True + _supports_flash_attn_2 = True + _supports_sdpa = True + _supports_quantized_cache = True + _supports_static_cache = True + _supports_attention_backend = True + + def _init_weights(self, module): + + # Use the language model's initializer_range if available + if hasattr(self.config, 'language_model_config') and hasattr(self.config.language_model_config, 'initializer_range'): + std = self.config.language_model_config.initializer_range + elif hasattr(self.config, 'decoder_config') and hasattr(self.config.decoder_config, 'initializer_range'): + std = self.config.decoder_config.initializer_range + else: + std = 0.02 # Default value + + if isinstance(module, nn.Linear): + module.weight.data.normal_(mean=0.0, std=std) + if module.bias is not None: + module.bias.data.zero_() + elif isinstance(module, nn.LayerNorm): + module.weight.data.fill_(1.0) + module.bias.data.zero_() + +# @auto_docstring +class VibeVoiceASRModel(VibeVoiceASRPreTrainedModel): + def __init__(self, config): + super().__init__(config) + + if hasattr(config, 'torch_dtype') and config.torch_dtype is not None: + if isinstance(config.torch_dtype, str): + dtype = getattr(torch, config.torch_dtype) + else: + dtype = config.torch_dtype + else: + dtype = torch.float32 + + # Initialize Qwen2 model for language modeling + lm_config = config.decoder_config + self.language_model = AutoModel.from_config(lm_config) + + # Initialize speech components if needed + self.acoustic_tokenizer = AutoModel.from_config(config.acoustic_tokenizer_config).to(dtype) + self.semantic_tokenizer = AutoModel.from_config(config.semantic_tokenizer_config).to(dtype) + + self.acoustic_connector = SpeechConnector(config.acoustic_vae_dim, lm_config.hidden_size).to(dtype) + self.semantic_connector = SpeechConnector(config.semantic_vae_dim, lm_config.hidden_size).to(dtype) + + def get_input_embeddings(self): + if hasattr(self.language_model, 'embed_tokens'): + # If the language model has an embed_tokens attribute, return it + return self.language_model.embed_tokens + + for name, attr in self.language_model.fullmap.items(): # parallel by nnscaler, the name is changed + if attr.orig_name == 'embed_tokens.weight': + return getattr(self.language_model, name) + assert False, 'should not arrive here' + + def set_input_embeddings(self, value): + self.language_model.embed_tokens = value + + def set_speech_tokenizers(self, acoustic_tokenizer=None, semantic_tokenizer=None): + """Set the speech tokenizers used for encoding and decoding speech.""" + self.acoustic_tokenizer = acoustic_tokenizer + self.semantic_tokenizer = semantic_tokenizer + + # Reset the encoder to evaluation mode + if self.acoustic_tokenizer is not None: + self.acoustic_tokenizer.eval() + + if self.semantic_tokenizer is not None: + self.semantic_tokenizer.eval() + + def forward( + self, + input_ids: torch.LongTensor = None, + attention_mask: Optional[torch.Tensor] = None, + position_ids: Optional[torch.LongTensor] = None, + past_key_values: Optional[Tuple[Tuple[torch.FloatTensor]]] = None, + inputs_embeds: Optional[torch.FloatTensor] = None, + use_cache: Optional[bool] = None, + output_attentions: Optional[bool] = None, + output_hidden_states: Optional[bool] = None, + return_dict: Optional[bool] = None, + cache_position: Optional[torch.LongTensor] = None, + **kwargs, + ) -> Union[Tuple, BaseModelOutputWithPast]: + + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + + # Forward through language model + outputs = self.language_model( + input_ids=input_ids, + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + inputs_embeds=inputs_embeds, + use_cache=use_cache, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + cache_position=cache_position, + **kwargs, + ) + + if not return_dict: + return outputs + + return BaseModelOutputWithPast( + last_hidden_state=outputs.last_hidden_state, + past_key_values=outputs.past_key_values, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + ) + +class VibeVoiceASRForConditionalGeneration(VibeVoiceASRPreTrainedModel, GenerationMixin): + """ + VibeVoice model for Automatic Speech Recognition (ASR) with language modeling head for conditional generation. + This class is designed for inference and generation tasks. + """ + _tied_weights_keys = ["lm_head.weight"] + _tp_plan = {"lm_head": "colwise_rep"} + + def __init__(self, config): + super().__init__(config) + self.model = VibeVoiceASRModel(config) + self.vocab_size = config.decoder_config.vocab_size + + # Determine the dtype to use + if hasattr(config, 'torch_dtype') and config.torch_dtype is not None: + if isinstance(config.torch_dtype, str): + dtype = getattr(torch, config.torch_dtype) + else: + dtype = config.torch_dtype + else: + dtype = torch.float32 + + # Initialize lm_head with the correct dtype + self.lm_head = nn.Linear(config.decoder_config.hidden_size, self.vocab_size, bias=False).to(dtype) + + # Initialize weights and apply final processing + self.post_init() + + def get_input_embeddings(self): + return self.model.get_input_embeddings() + + def set_input_embeddings(self, value): + self.model.set_input_embeddings(value) + + def get_output_embeddings(self): + return self.lm_head + + def set_output_embeddings(self, new_embeddings): + self.lm_head = new_embeddings + + def set_decoder(self, decoder): + self.model.language_model = decoder + + def get_decoder(self): + return self.model.language_model + + def tie_weights(self): + """Tie the weights between the input embeddings and the output embeddings.""" + if getattr(self.config.decoder_config, 'tie_word_embeddings', False): + output_embeddings = self.get_output_embeddings() + input_embeddings = self.get_input_embeddings() + if hasattr(input_embeddings, 'weight'): + output_embeddings.weight = input_embeddings.weight + else: + output_embeddings.weight = input_embeddings + + def encode_speech( + self, + speech_tensors: torch.FloatTensor, + speech_masks: Optional[torch.BoolTensor] = None, + speech_semantic_tensors: Optional[torch.FloatTensor] = None, + streaming_segment_duration: float = 60.0, # seconds + ): + """ + Encode speech input into features that can be used by the language model. + This method is called once before generation to process the speech input. + + For long audio (>600s by default), uses streaming processing to avoid conv overflow (>2^32). + Segments are processed independently, then concatenated before final sampling. + + Args: + speech_tensors: Input audio tensor [batch_size, samples] + speech_masks: Optional mask for speech features + speech_semantic_tensors: Optional pre-computed semantic tokens + streaming_segment_duration: Segment duration in seconds for streaming processing (default: 60s) + """ + if hasattr(self.config, 'torch_dtype') and self.config.torch_dtype is not None: + if isinstance(self.config.torch_dtype, str): + dtype = getattr(torch, self.config.torch_dtype) + else: + dtype = self.config.torch_dtype + else: + dtype = torch.float32 + + speech_tensors = speech_tensors.to(dtype) + + # Ensure proper shape: (batch, samples) + if speech_tensors.ndim == 1: + speech_tensors = speech_tensors.unsqueeze(0) + + batch_size, total_samples = speech_tensors.shape + sample_rate = 24000 # fix 24kHz sample rate + + # Calculate segment size in samples + segment_samples = int(streaming_segment_duration * sample_rate) + + # Decide whether to use streaming based on audio length + use_streaming = total_samples > segment_samples + + with torch.no_grad(): + if not use_streaming: + # Short audio: direct processing (original behavior) + encoder_output = self.model.acoustic_tokenizer.encode(speech_tensors.unsqueeze(1)) + audio_tokens = encoder_output.sample(dist_type=self.model.acoustic_tokenizer.std_dist_type)[0] + acoustic_features = self.model.acoustic_connector(audio_tokens) + + # Encode semantic features + if speech_semantic_tensors is not None: + semantic_features = self.model.semantic_connector(speech_semantic_tensors) + else: + semantic_tokens = self.model.semantic_tokenizer.encode(speech_tensors.unsqueeze(1)).mean + semantic_features = self.model.semantic_connector(semantic_tokens) + else: + # Long audio: streaming processing + # print(f"Using streaming processing for long audio: {total_samples/sample_rate:.1f}s " + # f"(segment size: {streaming_segment_duration}s)") + + # Initialize caches for both tokenizers + acoustic_encoder_cache = VibeVoiceTokenizerStreamingCache() + semantic_encoder_cache = VibeVoiceTokenizerStreamingCache() + acoustic_mean_segments = [] + semantic_mean_segments = [] + sample_indices = torch.arange(batch_size, device=speech_tensors.device) + + # Helper function from batch_asr_sft_cache.py + def _iter_segments(total_length: int, segment_length: int): + """Iterate over audio segments with a given segment length.""" + if segment_length <= 0: + raise ValueError("segment_length must be positive") + for start in range(0, total_length, segment_length): + end = min(start + segment_length, total_length) + if end > start: + yield start, end + + # Process each segment for both acoustic and semantic tokenizers + segments = list(_iter_segments(total_samples, segment_samples)) + num_segments = len(segments) + for seg_idx, (start, end) in enumerate(segments): + chunk = speech_tensors[:, start:end].contiguous() + if chunk.numel() == 0: + continue + + # Check if this is the final segment + is_final = (seg_idx == num_segments - 1) + + # Encode chunk for acoustic tokenizer (don't sample yet) + acoustic_encoder_output = self.model.acoustic_tokenizer.encode( + chunk.unsqueeze(1), + cache=acoustic_encoder_cache, + sample_indices=sample_indices, + use_cache=True, + is_final_chunk=is_final, + ) + acoustic_mean_segments.append(acoustic_encoder_output.mean) + + # Encode chunk for semantic tokenizer (take mean directly) + semantic_encoder_output = self.model.semantic_tokenizer.encode( + chunk.unsqueeze(1), + cache=semantic_encoder_cache, + sample_indices=sample_indices, + use_cache=True, + is_final_chunk=is_final, + ) + semantic_mean_segments.append(semantic_encoder_output.mean) + + # print(f"Processed {len(acoustic_mean_segments)} segments.") + # Concatenate all acoustic means and sample once + acoustic_mean_full = torch.cat(acoustic_mean_segments, dim=1).contiguous() + acoustic_encoder_output = VibeVoiceTokenizerEncoderOutput( + mean=acoustic_mean_full, + std=self.model.acoustic_tokenizer.fix_std + ) + audio_tokens = acoustic_encoder_output.sample( + dist_type=self.model.acoustic_tokenizer.std_dist_type + )[0] + acoustic_features = self.model.acoustic_connector(audio_tokens) + + # Concatenate all semantic means + semantic_tokens = torch.cat(semantic_mean_segments, dim=1).contiguous() + semantic_features = self.model.semantic_connector(semantic_tokens) + + # Combine acoustic and semantic features + if speech_masks is not None: + combined_features = acoustic_features[speech_masks] + semantic_features[speech_masks] + else: + combined_features = acoustic_features + semantic_features + + return combined_features + + def forward( + self, + input_ids: Optional[torch.LongTensor] = None, + attention_mask: Optional[torch.Tensor] = None, + position_ids: Optional[torch.LongTensor] = None, + past_key_values: Optional[List[torch.FloatTensor]] = None, + inputs_embeds: Optional[torch.FloatTensor] = None, + labels: Optional[torch.LongTensor] = None, + use_cache: Optional[bool] = None, + output_attentions: Optional[bool] = None, + output_hidden_states: Optional[bool] = None, + return_dict: Optional[bool] = None, + cache_position: Optional[torch.LongTensor] = None, + # Speech-specific arguments + speech_tensors: Optional[torch.FloatTensor] = None, + speech_masks: Optional[torch.BoolTensor] = None, + speech_semantic_tensors: Optional[torch.FloatTensor] = None, + acoustic_input_mask: Optional[torch.BoolTensor] = None, + **kwargs, + ) -> Union[Tuple, CausalLMOutput]: + """ + Forward pass for the model. Handles both training and generation scenarios. + """ + output_attentions = output_attentions if output_attentions is not None else self.config.output_attentions + output_hidden_states = output_hidden_states if output_hidden_states is not None else self.config.output_hidden_states + return_dict = return_dict if return_dict is not None else self.config.use_return_dict + use_cache = use_cache if use_cache is not None else self.config.use_cache + + # Process inputs + if inputs_embeds is None and input_ids is not None: + inputs_embeds = self.get_input_embeddings()(input_ids) + + # If we have speech input and acoustic_input_mask, encode and insert speech features + if speech_tensors is not None and acoustic_input_mask is not None: + speech_features = self.encode_speech( + speech_tensors=speech_tensors, + speech_masks=speech_masks, + speech_semantic_tensors=speech_semantic_tensors, + ) + inputs_embeds[acoustic_input_mask] = speech_features + + # Forward through the model + outputs = self.model( + input_ids=None, + attention_mask=attention_mask, + position_ids=position_ids, + past_key_values=past_key_values, + inputs_embeds=inputs_embeds, + use_cache=use_cache, + output_attentions=output_attentions, + output_hidden_states=output_hidden_states, + return_dict=return_dict, + cache_position=cache_position, + ) + + hidden_states = outputs[0] if not return_dict else outputs.last_hidden_state + logits = self.lm_head(hidden_states) + + loss = None + if labels is not None: + # Shift so that tokens < n predict n + shift_logits = logits[..., :-1, :].contiguous() + shift_labels = labels[..., 1:].contiguous() + # Flatten the tokens + loss_fct = nn.CrossEntropyLoss() + shift_logits = shift_logits.view(-1, self.vocab_size) + shift_labels = shift_labels.view(-1) + # Enable model parallelism + shift_labels = shift_labels.to(shift_logits.device) + loss = loss_fct(shift_logits, shift_labels) + + if not return_dict: + output = (logits,) + outputs[1:] + return (loss,) + output if loss is not None else output + + return VibeVoiceCausalLMOutputWithPast( + loss=loss, + logits=logits, + past_key_values=outputs.past_key_values, + hidden_states=outputs.hidden_states, + attentions=outputs.attentions, + ) + + def prepare_inputs_for_generation( + self, + input_ids, + past_key_values=None, + attention_mask=None, + inputs_embeds=None, + cache_position=None, + position_ids=None, + use_cache=True, + speech_tensors=None, + speech_masks=None, + speech_semantic_tensors=None, + acoustic_input_mask=None, + **kwargs, + ): + """ + Prepare inputs for generation step. This method is called by generate() + for each token generation step. + + Following Qwen2-VL's approach: speech inputs are only forwarded on the first pass + (when cache_position[0] == 0), and are excluded in subsequent generation steps. + """ + # If we have past key values, we only need to process the new tokens + if past_key_values is not None: + if isinstance(past_key_values, tuple): + past_length = past_key_values[0][0].shape[2] + else: + past_length = past_key_values.get_seq_length() + + # Keep only the new tokens + if input_ids is not None and input_ids.shape[1] > past_length: + input_ids = input_ids[:, past_length:] + + # Prepare position ids + if position_ids is None and attention_mask is not None: + position_ids = attention_mask.long().cumsum(-1) - 1 + position_ids.masked_fill_(attention_mask == 0, 1) + if past_key_values is not None and input_ids is not None: + position_ids = position_ids[:, -input_ids.shape[1]:] + + # Prepare cache position + if cache_position is None: + past_seen_tokens = past_key_values.get_seq_length() if past_key_values is not None else 0 + cache_position = torch.arange( + past_seen_tokens, + past_seen_tokens + (input_ids.shape[1] if input_ids is not None else inputs_embeds.shape[1]), + device=input_ids.device if input_ids is not None else inputs_embeds.device + ) + + # Prepare model inputs + if inputs_embeds is not None and past_key_values is None: + model_inputs = {"inputs_embeds": inputs_embeds} + else: + model_inputs = {"input_ids": input_ids} + + model_inputs.update( + { + "position_ids": position_ids, + "cache_position": cache_position, + "past_key_values": past_key_values, + "use_cache": use_cache, + "attention_mask": attention_mask, + } + ) + + # Following Qwen2-VL pattern: only include speech inputs on the first forward pass + # (when cache_position[0] == 0), exclude them in subsequent generation steps + if cache_position is not None and len(cache_position) > 0 and cache_position[0] == 0: + # First forward pass - include speech inputs if provided + model_inputs.update({ + "speech_tensors": speech_tensors, + "speech_masks": speech_masks, + "speech_semantic_tensors": speech_semantic_tensors, + "acoustic_input_mask": acoustic_input_mask, + }) + else: + # Subsequent generation steps - exclude speech inputs + model_inputs.update({ + "speech_tensors": None, + "speech_masks": None, + "speech_semantic_tensors": None, + "acoustic_input_mask": None, + }) + + # Include any remaining kwargs that might be needed + model_inputs.update(kwargs) + + return model_inputs + +AutoModel.register(VibeVoiceASRConfig, VibeVoiceASRModel) +AutoModelForCausalLM.register(VibeVoiceASRConfig, VibeVoiceASRForConditionalGeneration) + +__all__ = [ + "VibeVoiceASRPreTrainedModel", + "VibeVoiceASRModel", + "VibeVoiceASRForConditionalGeneration", +] \ No newline at end of file diff --git a/vibevoice/modular/modular_vibevoice_text_tokenizer.py b/vibevoice/modular/modular_vibevoice_text_tokenizer.py index bfa7bdd..da5669f 100644 --- a/vibevoice/modular/modular_vibevoice_text_tokenizer.py +++ b/vibevoice/modular/modular_vibevoice_text_tokenizer.py @@ -207,8 +207,107 @@ class VibeVoiceTextTokenizerFast(Qwen2TokenizerFast): """Id used for padding (returns -100 for loss masking).""" return self._pad_id +class VibeVoiceASRTextTokenizerFast(Qwen2TokenizerFast): + """ + Construct a "fast" VibeVoice tokenizer (backed by HuggingFace's *tokenizers* library). + Based on the Qwen2 tokenizer with additional special tokens for speech. + + Args: + vocab_file (`str`, *optional*): + Path to the vocabulary file. + merges_file (`str`, *optional*): + Path to the merges file. + tokenizer_file (`str`, *optional*): + Path to [tokenizers](https://github.com/huggingface/tokenizers) file. + unk_token (`str`, *optional*, defaults to `"<|endoftext|>"`): + The unknown token. + bos_token (`str`, *optional*): + The beginning of sequence token. Not used for vibevoice. + eos_token (`str`, *optional*, defaults to `"<|endoftext|>"`): + The end of sequence token. + pad_token (`str`, *optional*, defaults to `"<|endoftext|>"`): + The token used for padding. + """ + model_input_names = ["input_ids", "attention_mask"] + + def __init__( + self, + vocab_file=None, + merges_file=None, + tokenizer_file=None, + unk_token="<|endoftext|>", + bos_token=None, + eos_token="<|endoftext|>", + pad_token="<|endoftext|>", + add_prefix_space=False, + **kwargs, + ): + super().__init__( + vocab_file=vocab_file, + merges_file=merges_file, + tokenizer_file=tokenizer_file, + unk_token=unk_token, + bos_token=bos_token, + eos_token=eos_token, + pad_token=pad_token, + add_prefix_space=add_prefix_space, + **kwargs, + ) + + # Add VibeVoice-specific special tokens + self._add_vibevoice_special_tokens() + + # https://github.com/QwenLM/Qwen2.5-VL/blob/d2240f11656bfe404b9ba56db4e51cd09f522ff1/qwen-vl-finetune/qwenvl/data/data_qwen_packed.py#L57C5-L57C222 + self.chat_template = "{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}" + + def _add_vibevoice_special_tokens(self): + """Add VibeVoice-specific special tokens.""" + special_tokens = { + "additional_special_tokens": [ + "<|object_ref_start|>", # Speech start (reusing vision tokens) + "<|object_ref_end|>", # Speech end + "<|box_start|>", # Speech diffusion pad + ] + } + num_added = self.add_special_tokens(special_tokens) + + # Cache special token IDs + self._speech_start_id = self.convert_tokens_to_ids("<|object_ref_start|>") + self._speech_end_id = self.convert_tokens_to_ids("<|object_ref_end|>") + self._speech_pad_id = self.convert_tokens_to_ids("<|box_start|>") + + self._eos_id = self.eos_token_id # qwen2 / qwen3 + self._pad_id = self.convert_tokens_to_ids('<|image_pad|>') + + return num_added + + @property + def eos_id(self) -> int: + """Id of the end of sequence token.""" + return self._eos_id + + @property + def speech_start_id(self) -> int: + """Id of the speech start token.""" + return self._speech_start_id + + @property + def speech_end_id(self) -> int: + """Id of the speech end token.""" + return self._speech_end_id + + @property + def speech_pad_id(self) -> int: + """Id of the speech diffusion token.""" + return self._speech_pad_id + + @property + def pad_id(self) -> int: + return self._pad_id + __all__ = [ "VibeVoiceTextTokenizer", "VibeVoiceTextTokenizerFast", + "VibeVoiceASRTextTokenizerFast", ] \ No newline at end of file diff --git a/vibevoice/modular/modular_vibevoice_tokenizer.py b/vibevoice/modular/modular_vibevoice_tokenizer.py index 0031b26..454f9c1 100644 --- a/vibevoice/modular/modular_vibevoice_tokenizer.py +++ b/vibevoice/modular/modular_vibevoice_tokenizer.py @@ -17,7 +17,7 @@ from transformers.utils import logging from transformers.modeling_utils import PreTrainedModel from transformers.activations import ACT2FN -from .configuration_vibevoice import VibeVoiceAcousticTokenizerConfig +from .configuration_vibevoice import VibeVoiceAcousticTokenizerConfig, VibeVoiceSemanticTokenizerConfig logger = logging.get_logger(__name__) @@ -26,14 +26,13 @@ import os try: from apex.normalization.fused_layer_norm import fused_rms_norm_affine APEX_AVAILABLE = True - logger.info("APEX FusedRMSNorm is available and will be used for optimization") + # logger.info("APEX FusedRMSNorm is available and will be used for optimization") if int(os.getenv("OPTIMIZE_FOR_SPEED", "0")) == 0: APEX_AVAILABLE = False - logger.warning("APEX FusedRMSNorm is disabled by environment variable OPTIMIZE_FOR_SPEED=0") + # logger.warning("APEX FusedRMSNorm is disabled by environment variable OPTIMIZE_FOR_SPEED=0") except ImportError: APEX_AVAILABLE = False - logger.warning("APEX FusedRMSNorm not available, using native implementation") -# APEX_AVAILABLE=False + # logger.warning("APEX FusedRMSNorm not available, using native implementation") # Normalization modules class ConvLayerNorm(nn.LayerNorm): @@ -297,7 +296,8 @@ class SConv1d(nn.Module): cache: Optional[VibeVoiceTokenizerStreamingCache] = None, sample_indices: Optional[torch.Tensor] = None, use_cache: bool = False, - debug: bool = False) -> torch.Tensor: + debug: bool = False, + is_final_chunk: bool = False) -> torch.Tensor: """ Forward pass with optional streaming support via cache. @@ -307,6 +307,7 @@ class SConv1d(nn.Module): sample_indices: Indices identifying each sample for cache management use_cache: Whether to use cached states for streaming debug: Whether to print debug information + is_final_chunk: Whether this is the final chunk (adds extra padding for alignment) Returns: Output tensor @@ -322,12 +323,13 @@ class SConv1d(nn.Module): assert sample_indices is not None, "sample_indices must be provided for streaming mode" assert len(sample_indices) == B, "sample_indices must match batch size" - return self._forward_streaming(x, cache, sample_indices, debug) + return self._forward_streaming(x, cache, sample_indices, debug, is_final_chunk) def _forward_streaming(self, x: torch.Tensor, cache: VibeVoiceTokenizerStreamingCache, sample_indices: torch.Tensor, - debug: bool = False) -> torch.Tensor: + debug: bool = False, + is_final_chunk: bool = False) -> torch.Tensor: """Streaming forward pass with cache operations kept separate from compiled code""" B, C, T = x.shape @@ -350,6 +352,16 @@ class SConv1d(nn.Module): input_with_context = torch.cat([cached_states, x], dim=2) else: input_with_context = x + + # For final chunk, add extra padding to ensure ceil behavior (same as non-streaming) + if is_final_chunk: + extra_padding = get_extra_padding_for_conv1d( + input_with_context, self.kernel_size, self.stride, self.padding_total + ) + if extra_padding > 0: + input_with_context = pad1d(input_with_context, (0, extra_padding), mode=self.pad_mode) + if debug: + print(f"[DEBUG] Final chunk: added extra_padding={extra_padding}") if debug: print(f"[DEBUG] Input shape: {x.shape}, Cache shape: {cached_states.shape}, Combined: {input_with_context.shape}") @@ -684,6 +696,135 @@ class Block1D(nn.Module): return x +class TokenizerEncoder(nn.Module): + """ + Encoder component for the VibeVoice tokenizer that converts audio to latent representations. + + Args: + config: Configuration object with model parameters + """ + def __init__(self, config): + super().__init__() + + # Extract parameters from config + self.channels = config.channels + self.dimension = config.dimension + self.n_filters = config.n_filters + self.ratios = list(reversed(config.ratios)) + self.depths = config.depths + self.n_residual_layers = getattr(config, "n_residual_layers", 1) + self.hop_length = np.prod(self.ratios) + self.causal = config.causal + + # Additional config parameters with defaults + kernel_size = getattr(config, "kernel_size", 7) + last_kernel_size = getattr(config, "last_kernel_size", 7) + norm = getattr(config, "norm", "none") + norm_params = getattr(config, "norm_params", {}) + pad_mode = getattr(config, "pad_mode", "reflect") + bias = getattr(config, "bias", True) + layernorm = getattr(config, "layernorm", "LN") + layernorm_eps = getattr(config, "layernorm_eps", 1e-6) + layernorm_elementwise_affine = getattr(config, "layernorm_elementwise_affine", True) + drop_path_rate = getattr(config, "drop_path_rate", 0.0) + mixer_layer = getattr(config, "mixer_layer", "conv") + layer_scale_init_value = getattr(config, "layer_scale_init_value", 0) + disable_last_norm = getattr(config, "disable_last_norm", False) + + # determine the norm type based on layernorm + if layernorm == 'LN': + norm_type = ConvLayerNorm + elif layernorm == 'RMSNorm': + norm_type = partial(ConvRMSNorm, elementwise_affine=layernorm_elementwise_affine) + else: + raise ValueError(f"Unsupported norm type: {layernorm}") + + # stem and intermediate downsampling conv layers + stem = nn.Sequential( + SConv1d(self.channels, self.n_filters, kernel_size, norm=norm, norm_kwargs=norm_params, causal=self.causal, pad_mode=pad_mode, bias=bias), + ) + + self.downsample_layers = nn.ModuleList() + self.downsample_layers.append(stem) + for i in range(len(self.ratios)): + in_ch = self.n_filters * (2 ** i) + out_ch = self.n_filters * (2 ** (i + 1)) + downsample_layer = nn.Sequential( + SConv1d(in_ch, out_ch, kernel_size=self.ratios[i] * 2, stride=self.ratios[i], causal=self.causal, pad_mode=pad_mode, norm=norm, bias=bias) + ) + self.downsample_layers.append(downsample_layer) + + # configure the transformer blocks + layer_type = partial( + Block1D, + mixer_layer=mixer_layer, + layernorm=layernorm, + eps=layernorm_eps, + causal=self.causal, + pad_mode=pad_mode, + norm=norm, + bias=bias, + layer_scale_init_value=layer_scale_init_value, + ) + + self.stages = nn.ModuleList() + dp_rates = [x.item() for x in torch.linspace(0, drop_path_rate, sum(self.depths))] + cur = 0 + + for i in range(len(self.depths)): + in_ch = self.n_filters * (2 ** i) + stage = nn.Sequential( + *[layer_type(dim=in_ch, drop_path=dp_rates[cur + j]) for j in range(self.depths[i])] + ) + self.stages.append(stage) + cur += self.depths[i] + + if not disable_last_norm: + self.norm = norm_type(in_ch, eps=layernorm_eps) + else: + self.norm = nn.Identity() + self.head = SConv1d(in_ch, self.dimension, kernel_size=last_kernel_size, causal=self.causal, pad_mode=pad_mode, norm=norm, bias=bias) + + def forward_features(self, x, cache=None, sample_indices=None, use_cache=False, debug=False, is_final_chunk=False): + for i in range(len(self.depths)): + # Apply downsampling + for layer in self.downsample_layers[i]: + if isinstance(layer, SConv1d): + x = layer(x, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug, is_final_chunk=is_final_chunk) + else: + x = layer(x) + + # Apply stage (Block1D contains Convlayer which contains SConv1d) + for block in self.stages[i]: + if hasattr(block, 'mixer') and hasattr(block.mixer, 'conv') and isinstance(block.mixer.conv, SConv1d): + # Block1D forward with cache support + residual = x + x = block.norm(x) + x = block.mixer.conv(x, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug, is_final_chunk=is_final_chunk) + if block.gamma is not None: + x = x * block.gamma.unsqueeze(-1) + x = residual + x + + # FFN part + residual = x + x = block.ffn_norm(x) + x = x.permute(0, 2, 1) + x = block.ffn(x) + x = x.permute(0, 2, 1) + if block.ffn_gamma is not None: + x = x * block.ffn_gamma.unsqueeze(-1) + x = residual + x + else: + x = block(x) + + return self.norm(x) + + def forward(self, x, cache=None, sample_indices=None, use_cache=False, debug=False, is_final_chunk=False): + x = self.forward_features(x, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug, is_final_chunk=is_final_chunk) + x = self.head(x, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug, is_final_chunk=is_final_chunk) + return x + + class TokenizerDecoder(nn.Module): """ Decoder component for the VibeVoice tokenizer that converts latent representations back to audio. @@ -821,15 +962,63 @@ class TokenizerDecoder(nn.Module): x = self.head(x, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug) return x + +@dataclass +class VibeVoiceTokenizerEncoderOutput: + """ + Output of VibeVoice tokenizer encoder, representing a Gaussian distribution with fixed variance. + + Args: + mean (`torch.FloatTensor`): The mean parameters of the distribution. + std (`float` or `torch.FloatTensor`): Fixed standard deviation value. + """ + mean: torch.Tensor + std: Optional[Union[float, torch.Tensor]] = None + + def sample(self, dist_type='fix'): + """ + Sample from the distribution. + + Args: + dist_type (`str`): Sampling method, either 'fix' or 'gaussian'. + + Returns: + `torch.FloatTensor`: Sampled values. + `torch.FloatTensor` (optional): Standard deviation used (only when dist_type='gaussian'). + """ + if dist_type == 'fix': + x = self.mean + self.std * torch.randn_like(self.mean) + return x, self.std + elif dist_type == 'gaussian': + batch_size = self.mean.size(0) + value = self.std / 0.8 + std = torch.randn(batch_size, device=self.mean.device, dtype=self.mean.dtype) * value + + while std.dim() < self.mean.dim(): + std = std.unsqueeze(-1) + + x = self.mean + std * torch.randn_like(self.mean) + return x, std + else: + return self.mean, self.std + + def kl(self): + """Compute KL divergence between this distribution and a standard normal.""" + target = torch.zeros_like(self.mean) + return F.mse_loss(self.mean, target, reduction='none') + + def mode(self): + """Return the distribution mode (which is the mean for Gaussian).""" + return self.mean class VibeVoiceAcousticTokenizerModel(PreTrainedModel): - """VibeVoice speech tokenizer model (only decoder) for acoustic tokens""" + """VibeVoice speech tokenizer model combining encoder and decoder for acoustic tokens""" config_class = VibeVoiceAcousticTokenizerConfig base_model_prefix = "vibevoice_acoustic_tokenizer" _supports_flash_attn_2 = True _supports_sdpa = True - _no_split_modules = ["TokenizerDecoder"] + _no_split_modules = ["TokenizerEncoder", "TokenizerDecoder"] def __init__(self, config): super().__init__(config) @@ -850,6 +1039,21 @@ class VibeVoiceAcousticTokenizerModel(PreTrainedModel): # Default: use reversed encoder depths if decoder_depths is None decoder_depths = list(reversed(encoder_depths)) + # Create encoder config + encoder_config = copy.deepcopy(config) + encoder_config.dimension = config.vae_dim + encoder_config.n_filters = config.encoder_n_filters + encoder_config.ratios = config.encoder_ratios + encoder_config.depths = encoder_depths + encoder_config.norm = config.conv_norm + encoder_config.pad_mode = config.pad_mode + encoder_config.bias = config.conv_bias + encoder_config.layernorm_eps = config.layernorm_eps + encoder_config.layernorm_elementwise_affine = config.layernorm_elementwise_affine + encoder_config.mixer_layer = config.mixer_layer + encoder_config.layer_scale_init_value = config.layer_scale_init_value + encoder_config.disable_last_norm = config.disable_last_norm + # Create decoder config decoder_config = copy.deepcopy(config) decoder_config.dimension = config.vae_dim @@ -865,6 +1069,8 @@ class VibeVoiceAcousticTokenizerModel(PreTrainedModel): decoder_config.layer_scale_init_value = config.layer_scale_init_value decoder_config.disable_last_norm = config.disable_last_norm + # Initialize encoder and decoder + self.encoder = TokenizerEncoder(encoder_config) self.decoder = TokenizerDecoder(decoder_config) # Initialize weights @@ -884,6 +1090,24 @@ class VibeVoiceAcousticTokenizerModel(PreTrainedModel): if module.bias is not None: nn.init.zeros_(module.bias) + @torch.no_grad() + def encode(self, audio, cache=None, sample_indices=None, use_cache=False, debug=False, is_final_chunk=False): + """Convert audio to latent representations""" + latents = self.encoder(audio, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug, is_final_chunk=is_final_chunk) + return VibeVoiceTokenizerEncoderOutput(mean=latents.permute(0, 2, 1), std=self.fix_std) + + @torch.no_grad() + def sampling(self, encoder_output, dist_type=None): + """Sample from the encoder output distribution""" + dist_type = dist_type or self.std_dist_type + + if dist_type == 'fix': + return encoder_output.sample(dist_type='fix') + elif dist_type == 'gaussian': + return encoder_output.sample(dist_type='gaussian') + else: + raise ValueError(f"Unsupported dist_type: {dist_type}, expected 'fix' or 'gaussian'") + @torch.no_grad() def decode(self, latents, cache=None, sample_indices=None, use_cache=False, debug=False): """Convert latent representations back to audio""" @@ -895,10 +1119,89 @@ class VibeVoiceAcousticTokenizerModel(PreTrainedModel): audio = self.decoder(latents, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug) return audio + def forward(self, audio, cache=None, sample_indices=None, use_cache=False, debug=False): + """Full forward pass: encode audio to latents, then decode back to audio""" + encoder_output = self.encode(audio, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug) + sampled_latents, _ = self.sampling(encoder_output) + reconstructed = self.decode(sampled_latents, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug) + return reconstructed, sampled_latents + + +class VibeVoiceSemanticTokenizerModel(PreTrainedModel): + """VibeVoice speech tokenizer model with only encoder for semantic tokens""" + + config_class = VibeVoiceSemanticTokenizerConfig + base_model_prefix = "vibevoice_semantic_tokenizer" + _supports_flash_attn_2 = True + _supports_sdpa = True + _no_split_modules = ["TokenizerEncoder"] + + def __init__(self, config): + super().__init__(config) + + # Parse encoder depths + if isinstance(config.encoder_depths, str): + encoder_depths = [int(d) for d in config.encoder_depths.split('-')] + else: + encoder_depths = config.encoder_depths + + # Create encoder config + encoder_config = copy.deepcopy(config) + encoder_config.dimension = config.vae_dim + encoder_config.n_filters = config.encoder_n_filters + encoder_config.ratios = config.encoder_ratios + encoder_config.depths = encoder_depths + encoder_config.norm = config.conv_norm + encoder_config.pad_mode = config.pad_mode + encoder_config.bias = config.conv_bias + encoder_config.layernorm_eps = config.layernorm_eps + encoder_config.layernorm_elementwise_affine = config.layernorm_elementwise_affine + encoder_config.mixer_layer = config.mixer_layer + encoder_config.layer_scale_init_value = config.layer_scale_init_value + encoder_config.disable_last_norm = config.disable_last_norm + + # Initialize encoder and decoder + self.encoder = TokenizerEncoder(encoder_config) + + # Initialize weights + self.apply(self._init_weights) + + def _init_weights(self, module): + """Initialize weights for the model""" + if isinstance(module, nn.Linear): + nn.init.normal_(module.weight, std=self.config.weight_init_value) + if module.bias is not None: + nn.init.zeros_(module.bias) + elif isinstance(module, nn.LayerNorm): + nn.init.ones_(module.weight) + nn.init.zeros_(module.bias) + elif isinstance(module, nn.Conv1d): + nn.init.normal_(module.weight, std=self.config.weight_init_value) + if module.bias is not None: + nn.init.zeros_(module.bias) + + @torch.no_grad() + def encode(self, audio, cache=None, sample_indices=None, use_cache=False, debug=False, is_final_chunk=False): + """Convert audio to latent representations""" + latents = self.encoder(audio, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug, is_final_chunk=is_final_chunk) + return VibeVoiceTokenizerEncoderOutput(mean=latents.permute(0, 2, 1)) + + @torch.no_grad() + def sampling(self, encoder_output, dist_type=None): + """Sample from the encoder output distribution""" + return encoder_output.sample(dist_type='none') + + def forward(self, audio, cache=None, sample_indices=None, use_cache=False, debug=False): + """Full forward pass: encode audio to latents, then decode back to audio""" + encoder_output = self.encode(audio, cache=cache, sample_indices=sample_indices, use_cache=use_cache, debug=debug) + sampled_latents, _ = self.sampling(encoder_output, dist_type='none') + return None, sampled_latents AutoModel.register(VibeVoiceAcousticTokenizerConfig, VibeVoiceAcousticTokenizerModel) +AutoModel.register(VibeVoiceSemanticTokenizerConfig, VibeVoiceSemanticTokenizerModel) __all__ = [ "VibeVoiceTokenizerStreamingCache", "VibeVoiceAcousticTokenizerModel", + "VibeVoiceSemanticTokenizerModel", ] \ No newline at end of file diff --git a/vibevoice/processor/audio_utils.py b/vibevoice/processor/audio_utils.py new file mode 100644 index 0000000..ad4d10d --- /dev/null +++ b/vibevoice/processor/audio_utils.py @@ -0,0 +1,143 @@ +import numpy as np +from subprocess import run +from typing import List, Optional, Union, Dict, Any + +COMMON_AUDIO_EXTS = [ + '.mp3', '.MP3', '.Mp3', # All case variations of mp3 + '.m4a', + '.mp4', '.MP4', + '.wav', '.WAV', + '.m4v', + '.aac', + '.ogg', + '.mov', '.MOV', + '.opus', + '.m4b', + '.flac', + '.wma', '.WMA', + '.rm', '.3gp', '.mpeg', '.flv', '.webm', '.mp2', '.aif', '.aiff', '.oga', '.ogv', '.mpga', '.m3u8', '.amr' +] + +def load_audio_use_ffmpeg(file: str, resample: bool = False, target_sr: int = 24000): + """ + Open an audio file and read as mono waveform, optionally resampling. + Returns both the audio data and the original sample rate. + + Parameters + ---------- + file: str + The audio file to open + resample: bool + Whether to resample the audio + target_sr: int + The target sample rate if resampling is requested + + Returns + ------- + A tuple containing: + - A NumPy array with the audio waveform in float32 dtype + - The original sample rate of the audio file + """ + if not resample: + # First, get the original sample rate + cmd_probe = [ + "ffprobe", + "-v", "quiet", + "-show_entries", "stream=sample_rate", + "-of", "default=noprint_wrappers=1:nokey=1", + file + ] + + original_sr = int(run(cmd_probe, capture_output=True, check=True).stdout.decode().strip()) + else: + original_sr = None + + # Now load the audio + sr_to_use = target_sr if resample else original_sr + + cmd = [ + "ffmpeg", + "-nostdin", + "-threads", "0", + "-i", file, + "-f", "s16le", + "-ac", "1", + "-acodec", "pcm_s16le", + "-ar", str(sr_to_use), + "-" + ] + + out = run(cmd, capture_output=True, check=True).stdout + audio_data = np.frombuffer(out, np.int16).flatten().astype(np.float32) / 32768.0 + + return audio_data, sr_to_use + +class AudioNormalizer: + """ + Audio normalization class for VibeVoice tokenizer. + + This class provides audio normalization to ensure consistent input levels + for the VibeVoice tokenizer while maintaining audio quality. + """ + + def __init__(self, target_dB_FS: float = -25, eps: float = 1e-6): + """ + Initialize the audio normalizer. + + Args: + target_dB_FS (float): Target dB FS level for the audio. Default: -25 + eps (float): Small value to avoid division by zero. Default: 1e-6 + """ + self.target_dB_FS = target_dB_FS + self.eps = eps + + def tailor_dB_FS(self, audio: np.ndarray) -> tuple: + """ + Adjust the audio to the target dB FS level. + + Args: + audio (np.ndarray): Input audio signal + + Returns: + tuple: (normalized_audio, rms, scalar) + """ + rms = np.sqrt(np.mean(audio**2)) + scalar = 10 ** (self.target_dB_FS / 20) / (rms + self.eps) + normalized_audio = audio * scalar + return normalized_audio, rms, scalar + + def avoid_clipping(self, audio: np.ndarray, scalar: Optional[float] = None) -> tuple: + """ + Avoid clipping by scaling down if necessary. + + Args: + audio (np.ndarray): Input audio signal + scalar (float, optional): Explicit scaling factor + + Returns: + tuple: (normalized_audio, scalar) + """ + if scalar is None: + max_val = np.max(np.abs(audio)) + if max_val > 1.0: + scalar = max_val + self.eps + else: + scalar = 1.0 + + return audio / scalar, scalar + + def __call__(self, audio: np.ndarray) -> np.ndarray: + """ + Normalize the audio by adjusting to target dB FS and avoiding clipping. + + Args: + audio (np.ndarray): Input audio signal + + Returns: + np.ndarray: Normalized audio signal + """ + # First adjust to target dB FS + audio, _, _ = self.tailor_dB_FS(audio) + # Then avoid clipping + audio, _ = self.avoid_clipping(audio) + return audio \ No newline at end of file diff --git a/vibevoice/processor/vibevoice_asr_processor.py b/vibevoice/processor/vibevoice_asr_processor.py new file mode 100644 index 0000000..007dc39 --- /dev/null +++ b/vibevoice/processor/vibevoice_asr_processor.py @@ -0,0 +1,572 @@ +""" +Processor class for VibeVoice ASR models. +""" + +import os +import json +import math +import warnings +from typing import List, Optional, Union, Dict, Any, Tuple + +import numpy as np +import torch + +from transformers.tokenization_utils_base import BatchEncoding +from transformers.utils import TensorType, logging +from .vibevoice_tokenizer_processor import VibeVoiceTokenizerProcessor, AudioNormalizer + +try: + from .audio_utils import load_audio_use_ffmpeg + HAS_FFMPEG_UTILS = True +except ImportError: + HAS_FFMPEG_UTILS = False + warnings.warn("audio_utils not available, will fall back to soundfile for audio loading") + +logger = logging.get_logger(__name__) + +SYSTEM_PROMPT = "You are a helpful assistant that transcribes audio input into text output in JSON format." + + +class VibeVoiceASRProcessor: + """ + Processor for VibeVoice ASR (Automatic Speech Recognition) models. + + This processor handles audio preprocessing and tokenization for ASR tasks, + following the exact format used in training with proper chat templates. + + Args: + tokenizer: The text tokenizer for processing text + audio_processor: The audio processor for processing speech + speech_tok_compress_ratio (int): Compression ratio for speech tokenization + target_sample_rate (int): Target sample rate for audio + normalize_audio (bool): Whether to normalize audio input + """ + + def __init__( + self, + tokenizer=None, + audio_processor=None, + speech_tok_compress_ratio=320, + target_sample_rate=24000, + normalize_audio=True, + **kwargs + ): + self.tokenizer = tokenizer + self.audio_processor = audio_processor or VibeVoiceTokenizerProcessor( + sampling_rate=target_sample_rate, + normalize_audio=normalize_audio + ) + self.speech_tok_compress_ratio = speech_tok_compress_ratio + self.target_sample_rate = target_sample_rate + self.normalize_audio = normalize_audio + + if normalize_audio: + self.audio_normalizer = AudioNormalizer() + else: + self.audio_normalizer = None + + # Cache special token IDs + self._cache_special_tokens() + + def _cache_special_tokens(self): + """Cache special token IDs for efficiency.""" + # Add safety checks for special tokens + if hasattr(self.tokenizer, 'speech_start_id'): + self.speech_start_id = self.tokenizer.speech_start_id + else: + self.speech_start_id = self.tokenizer.convert_tokens_to_ids("<|speech_start|>") + + if hasattr(self.tokenizer, 'speech_end_id'): + self.speech_end_id = self.tokenizer.speech_end_id + else: + self.speech_end_id = self.tokenizer.convert_tokens_to_ids("<|speech_end|>") + + if hasattr(self.tokenizer, 'speech_pad_id'): + self.speech_pad_id = self.tokenizer.speech_pad_id + else: + self.speech_pad_id = self.tokenizer.convert_tokens_to_ids("<|speech_pad|>") + + if hasattr(self.tokenizer, 'pad_id'): + self.pad_id = self.tokenizer.pad_id + elif hasattr(self.tokenizer, 'pad_token_id'): + self.pad_id = self.tokenizer.pad_token_id + else: + self.pad_id = self.tokenizer.convert_tokens_to_ids("<|endoftext|>") + + @classmethod + def from_pretrained(cls, pretrained_model_name_or_path, **kwargs): + """ + Load processor from a pretrained model path. + + Args: + pretrained_model_name_or_path: Path to the pretrained model + **kwargs: Additional keyword arguments + + Returns: + VibeVoiceASRProcessor: The loaded processor + """ + import json + from transformers.utils import cached_file + from vibevoice.modular.modular_vibevoice_text_tokenizer import VibeVoiceASRTextTokenizerFast + + # Try to load configuration + config_path = os.path.join(pretrained_model_name_or_path, "preprocessor_config.json") + config = {} + + if os.path.exists(config_path): + with open(config_path, 'r') as f: + config = json.load(f) + else: + try: + config_file = cached_file( + pretrained_model_name_or_path, + "preprocessor_config.json", + **kwargs + ) + with open(config_file, 'r') as f: + config = json.load(f) + except Exception as e: + logger.warning(f"Could not load preprocessor_config.json: {e}") + logger.warning("Using default configuration") + + # Extract parameters + speech_tok_compress_ratio = config.get("speech_tok_compress_ratio", 3200) + target_sample_rate = config.get("target_sample_rate", 24000) + normalize_audio = config.get("normalize_audio", True) + + # Load tokenizer + language_model_pretrained_name = config.get("language_model_pretrained_name", None) or kwargs.pop("language_model_pretrained_name", "Qwen/Qwen2.5-1.5B") + logger.info(f"Loading tokenizer from {language_model_pretrained_name}") + + if 'qwen' in language_model_pretrained_name.lower(): + tokenizer = VibeVoiceASRTextTokenizerFast.from_pretrained( + language_model_pretrained_name, + **kwargs + ) + else: + raise ValueError(f"Unsupported tokenizer type for {language_model_pretrained_name}") + + # Load audio processor + audio_processor = VibeVoiceTokenizerProcessor( + sampling_rate=target_sample_rate, + normalize_audio=normalize_audio, + target_dB_FS=config.get("target_dB_FS", -25), + eps=config.get("eps", 1e-6), + ) + + return cls( + tokenizer=tokenizer, + audio_processor=audio_processor, + speech_tok_compress_ratio=speech_tok_compress_ratio, + target_sample_rate=target_sample_rate, + normalize_audio=normalize_audio, + ) + + def save_pretrained(self, save_directory: Union[str, os.PathLike], **kwargs): + """ + Save processor configuration to a directory. + + Args: + save_directory: Directory to save the configuration + **kwargs: Additional keyword arguments + """ + import json + + os.makedirs(save_directory, exist_ok=True) + + # Save processor configuration + processor_config = { + "processor_class": "VibeVoiceASRProcessor", + "speech_tok_compress_ratio": self.speech_tok_compress_ratio, + "target_sample_rate": self.target_sample_rate, + "normalize_audio": self.normalize_audio, + "target_dB_FS": -25, + "eps": 1e-6, + } + + config_path = os.path.join(save_directory, "preprocessor_config.json") + with open(config_path, 'w') as f: + json.dump(processor_config, f, indent=2) + + logger.info(f"Processor configuration saved in {config_path}") + + def __call__( + self, + audio: Optional[Union[str, np.ndarray, torch.Tensor, List[Union[str, np.ndarray, torch.Tensor]]]] = None, + sampling_rate: Optional[int] = None, + return_tensors: Optional[Union[str, TensorType]] = None, + padding: bool = True, + max_length: Optional[int] = None, + truncation: bool = False, + add_generation_prompt: bool = True, + use_streaming: bool = True, + context_info: Optional[str] = None, + **kwargs + ) -> BatchEncoding: + """ + Process audio input for ASR model. + + Args: + audio: Audio input(s). Can be: + - str: Path to audio file + - np.ndarray: Audio array + - torch.Tensor: Audio tensor + - List of the above for batch processing + sampling_rate: Sampling rate of input audio + return_tensors: Output format ('pt' for PyTorch, 'np' for NumPy) + padding: Whether to pad batch inputs + max_length: Maximum sequence length + truncation: Whether to truncate long sequences + add_generation_prompt: Whether to add generation prompt for inference + use_streaming: Whether to use streaming mode (True by default, auto False if <60s) + context_info: Optional context information (e.g., hotwords, metadata) to help transcription + + Returns: + BatchEncoding with: + - input_ids: Token IDs for the model + - attention_mask: Attention mask + - acoustic_input_mask: Mask indicating speech token positions + - speech_tensors: Processed speech features + - speech_masks: Valid speech masks + - vae_tok_seqlens: Length of each speech segment in tokens + """ + if audio is None: + raise ValueError("Audio input is required for ASR processing") + + # Handle single vs batch input + if isinstance(audio, list): + is_batched = True + audio_list = audio + else: + is_batched = False + audio_list = [audio] + + # Process each audio input + all_encodings = [] + for audio_input in audio_list: + encoding = self._process_single_audio( + audio_input, + sampling_rate=sampling_rate, + add_generation_prompt=add_generation_prompt, + use_streaming=use_streaming, + context_info=context_info, + ) + all_encodings.append(encoding) + + # Combine into batch + batch_encoding = self._batch_encode( + all_encodings, + padding=padding, + max_length=max_length, + truncation=truncation, + return_tensors=return_tensors, + ) + + return batch_encoding + + def _process_single_audio( + self, + audio: Union[str, np.ndarray, torch.Tensor], + sampling_rate: Optional[int] = None, + add_generation_prompt: bool = True, + use_streaming: bool = True, + context_info: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Process a single audio input. + + Args: + audio: Single audio input + sampling_rate: Audio sampling rate + add_generation_prompt: Whether to add generation prompt + context_info: Optional context information (e.g., hotwords, metadata) to help transcription + + Returns: + Dictionary with processed tokens and audio features + """ + # Process audio through audio processor + if isinstance(audio, str): + # Load from file using ffmpeg for better format support + if HAS_FFMPEG_UTILS: + try: + audio_array, file_sr = load_audio_use_ffmpeg(audio, resample=False) + except Exception as e: + # Fall back to soundfile if ffmpeg fails + warnings.warn(f"ffmpeg loading failed, falling back to soundfile: {e}") + import soundfile as sf + audio_array, file_sr = sf.read(audio) + if audio_array.ndim > 1: + audio_array = audio_array.mean(axis=1) # Convert to mono + else: + import soundfile as sf + audio_array, file_sr = sf.read(audio) + if audio_array.ndim > 1: + audio_array = audio_array.mean(axis=1) # Convert to mono + + # Resample if needed + if file_sr != self.target_sample_rate: + import librosa + audio_array = librosa.resample( + audio_array, + orig_sr=file_sr, + target_sr=self.target_sample_rate + ) + elif isinstance(audio, torch.Tensor): + audio_array = audio.cpu().numpy() + if audio_array.ndim > 1: + audio_array = audio_array.squeeze() + else: + audio_array = np.array(audio, dtype=np.float32) + if audio_array.ndim > 1: + audio_array = audio_array.squeeze() + + # Ensure float32 + audio_array = audio_array.astype(np.float32) + + # Normalize if needed + if self.normalize_audio and self.audio_normalizer: + audio_array = self.audio_normalizer(audio_array) + + # Calculate audio duration + audio_duration = len(audio_array) / self.target_sample_rate + + # Auto-disable streaming for short audio (<60s) + if use_streaming and audio_duration < 60.0: + use_streaming = False + + # Calculate token length based on streaming mode + # Non-streaming: uses ceil (encoder adds extra_padding for stride alignment) + # Streaming: uses floor (segments processed independently, no global alignment) + # if use_streaming: + # vae_tok_len = len(audio_array) // self.speech_tok_compress_ratio + # else: + vae_tok_len = math.ceil(len(audio_array) / self.speech_tok_compress_ratio) + + # Build token sequence following training format + # 1. System prompt - use apply_chat_template then encode like in training + system_prompt_text = self.tokenizer.apply_chat_template( + [{"role": "system", "content": SYSTEM_PROMPT}], + tokenize=False + ) + system_tokens = self.tokenizer.encode(system_prompt_text) + + # 2. User input with speech tokens + # Build speech placeholder string + sp_start_token = self.tokenizer.convert_ids_to_tokens(self.speech_start_id) + sp_pad_token = self.tokenizer.convert_ids_to_tokens(self.speech_pad_id) + sp_end_token = self.tokenizer.convert_ids_to_tokens(self.speech_end_id) + + # User suffix with audio duration info + show_keys = ['Start time', 'End time', 'Speaker ID', 'Content'] + if context_info and context_info.strip(): + user_suffix = f"This is a {audio_duration:.2f} seconds audio, with extra info: {context_info.strip()}\n\nPlease transcribe it with these keys: " + ", ".join(show_keys) + else: + user_suffix = f"This is a {audio_duration:.2f} seconds audio, please transcribe it with these keys: " + ", ".join(show_keys) + + user_input_string = ''.join( + [sp_start_token] + [sp_pad_token] * vae_tok_len + [sp_end_token] + ) + '\n' + user_suffix + + user_tokens = self.tokenizer.apply_chat_template( + [{"role": "user", "content": user_input_string}], + tokenize=True + ) + + # Combine tokens + full_tokens = system_tokens + user_tokens + + # Create acoustic input mask + acoustic_input_mask = [1 if token == self.speech_pad_id else 0 for token in full_tokens] + + return { + "input_ids": full_tokens, + "acoustic_input_mask": acoustic_input_mask, + "speech": audio_array, + "vae_tok_len": vae_tok_len, + } + + def _batch_encode( + self, + encodings: List[Dict[str, Any]], + padding: bool = True, + max_length: Optional[int] = None, + truncation: bool = False, + return_tensors: Optional[str] = None, + ) -> BatchEncoding: + """ + Combine multiple encodings into a batch. + + Args: + encodings: List of encoded samples + padding: Whether to pad sequences + max_length: Maximum sequence length + truncation: Whether to truncate + return_tensors: Output format + + Returns: + BatchEncoding with batched data + """ + # Extract components + input_ids_list = [enc["input_ids"] for enc in encodings] + acoustic_masks_list = [enc["acoustic_input_mask"] for enc in encodings] + speech_list = [enc["speech"] for enc in encodings] + vae_tok_lens = [enc["vae_tok_len"] for enc in encodings] + + # Determine max length for padding + if padding: + if max_length is not None: + target_length = max_length + else: + target_length = max(len(ids) for ids in input_ids_list) + + # Pad sequences + padded_input_ids = [] + padded_acoustic_masks = [] + attention_masks = [] + + for input_ids, acoustic_mask in zip(input_ids_list, acoustic_masks_list): + # Truncate if needed + if truncation and len(input_ids) > target_length: + input_ids = input_ids[:target_length] + acoustic_mask = acoustic_mask[:target_length] + + # Pad sequences to left (for autoregressive generation) + padding_length = target_length - len(input_ids) + padded_ids = [self.pad_id] * padding_length + input_ids + padded_acoustic = [0] * padding_length + acoustic_mask + attention_mask = [0] * padding_length + [1] * len(input_ids) + + padded_input_ids.append(padded_ids) + padded_acoustic_masks.append(padded_acoustic) + attention_masks.append(attention_mask) + + input_ids_list = padded_input_ids + acoustic_masks_list = padded_acoustic_masks + else: + attention_masks = [[1] * len(ids) for ids in input_ids_list] + + # Process speech tensors - raw audio is 1D, so we keep it as is + max_speech_length = max(len(s) for s in speech_list) + padded_speeches = np.zeros((len(speech_list), max_speech_length), dtype=np.float32) + speech_masks = np.zeros((len(speech_list), max(vae_tok_lens)), dtype=bool) + + for i, (speech, vae_len) in enumerate(zip(speech_list, vae_tok_lens)): + padded_speeches[i, :len(speech)] = speech + speech_masks[i, :vae_len] = True + + # Create batch encoding + batch_encoding = BatchEncoding() + + if return_tensors == "pt": + batch_encoding["input_ids"] = torch.tensor(input_ids_list, dtype=torch.long) + batch_encoding["attention_mask"] = torch.tensor(attention_masks, dtype=torch.long) + batch_encoding["acoustic_input_mask"] = torch.tensor(acoustic_masks_list, dtype=torch.bool) + batch_encoding["speech_tensors"] = torch.tensor(padded_speeches, dtype=torch.float32) + batch_encoding["speech_masks"] = torch.tensor(speech_masks, dtype=torch.bool) + # Note: vae_tok_seqlens and speech_type are not included as they are not model inputs + else: + batch_encoding["input_ids"] = input_ids_list if len(input_ids_list) > 1 else input_ids_list[0] + batch_encoding["attention_mask"] = attention_masks if len(attention_masks) > 1 else attention_masks[0] + batch_encoding["acoustic_input_mask"] = acoustic_masks_list if len(acoustic_masks_list) > 1 else acoustic_masks_list[0] + batch_encoding["speech_tensors"] = padded_speeches if len(padded_speeches) > 1 else padded_speeches[0] + batch_encoding["speech_masks"] = speech_masks if len(speech_masks) > 1 else speech_masks[0] + + return batch_encoding + + def batch_decode(self, *args, **kwargs): + """ + Decode batch of token IDs to text. + Forwards to tokenizer's batch_decode method. + """ + return self.tokenizer.batch_decode(*args, **kwargs) + + def decode(self, *args, **kwargs): + """ + Decode token IDs to text. + Forwards to tokenizer's decode method. + """ + return self.tokenizer.decode(*args, **kwargs) + + def post_process_transcription(self, text: str) -> List[Dict[str, Any]]: + """ + Post-process the generated transcription text to extract structured data. + + Args: + text: Generated text from the model + + Returns: + List of dictionaries with transcription segments + """ + try: + # Try to parse as JSON + if "```json" in text: + # Extract JSON from markdown code block + json_start = text.find("```json") + 7 + json_end = text.find("```", json_start) + json_str = text[json_start:json_end].strip() + else: + # Try to find JSON array or object + json_start = text.find("[") + if json_start == -1: + json_start = text.find("{") + if json_start != -1: + # Find matching closing bracket + bracket_count = 0 + json_end = json_start + for i in range(json_start, len(text)): + if text[i] in "[{": + bracket_count += 1 + elif text[i] in "]}": + bracket_count -= 1 + if bracket_count == 0: + json_end = i + 1 + break + json_str = text[json_start:json_end] + else: + json_str = text + + # Parse JSON + result = json.loads(json_str) + + # Ensure it's a list + if isinstance(result, dict): + result = [result] + + # Validate and clean up the result + cleaned_result = [] + for item in result: + if isinstance(item, dict): + cleaned_item = {} + # Map keys to expected format + key_mapping = { + "Start time": "start_time", + "Start": "start_time", + "End time": "end_time", + "End": "end_time", + "Speaker ID": "speaker_id", + "Speaker": "speaker_id", + "Content": "text", + } + for key, mapped_key in key_mapping.items(): + if key in item: + cleaned_item[mapped_key] = item[key] + + if cleaned_item: + cleaned_result.append(cleaned_item) + + return cleaned_result + + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse JSON from transcription: {e}") + logger.debug(f"Raw text: {text}") + return [] + except Exception as e: + logger.warning(f"Error post-processing transcription: {e}") + return [] + + @property + def model_input_names(self): + """Return the list of inputs accepted by the model.""" + return ["input_ids", "attention_mask", "acoustic_input_mask", "speech_tensors", "speech_masks"] + +__all__ = ["VibeVoiceASRProcessor"] diff --git a/vibevoice/processor/vibevoice_tokenizer_processor.py b/vibevoice/processor/vibevoice_tokenizer_processor.py index 19b5795..67f61a6 100644 --- a/vibevoice/processor/vibevoice_tokenizer_processor.py +++ b/vibevoice/processor/vibevoice_tokenizer_processor.py @@ -13,80 +13,10 @@ import torch from transformers.feature_extraction_utils import FeatureExtractionMixin from transformers.utils import logging +from .audio_utils import AudioNormalizer + logger = logging.get_logger(__name__) - -class AudioNormalizer: - """ - Audio normalization class for VibeVoice tokenizer. - - This class provides audio normalization to ensure consistent input levels - for the VibeVoice tokenizer while maintaining audio quality. - """ - - def __init__(self, target_dB_FS: float = -25, eps: float = 1e-6): - """ - Initialize the audio normalizer. - - Args: - target_dB_FS (float): Target dB FS level for the audio. Default: -25 - eps (float): Small value to avoid division by zero. Default: 1e-6 - """ - self.target_dB_FS = target_dB_FS - self.eps = eps - - def tailor_dB_FS(self, audio: np.ndarray) -> tuple: - """ - Adjust the audio to the target dB FS level. - - Args: - audio (np.ndarray): Input audio signal - - Returns: - tuple: (normalized_audio, rms, scalar) - """ - rms = np.sqrt(np.mean(audio**2)) - scalar = 10 ** (self.target_dB_FS / 20) / (rms + self.eps) - normalized_audio = audio * scalar - return normalized_audio, rms, scalar - - def avoid_clipping(self, audio: np.ndarray, scalar: Optional[float] = None) -> tuple: - """ - Avoid clipping by scaling down if necessary. - - Args: - audio (np.ndarray): Input audio signal - scalar (float, optional): Explicit scaling factor - - Returns: - tuple: (normalized_audio, scalar) - """ - if scalar is None: - max_val = np.max(np.abs(audio)) - if max_val > 1.0: - scalar = max_val + self.eps - else: - scalar = 1.0 - - return audio / scalar, scalar - - def __call__(self, audio: np.ndarray) -> np.ndarray: - """ - Normalize the audio by adjusting to target dB FS and avoiding clipping. - - Args: - audio (np.ndarray): Input audio signal - - Returns: - np.ndarray: Normalized audio signal - """ - # First adjust to target dB FS - audio, _, _ = self.tailor_dB_FS(audio) - # Then avoid clipping - audio, _ = self.avoid_clipping(audio) - return audio - - # Change from ProcessorMixin to FeatureExtractionMixin which is designed for single components class VibeVoiceTokenizerProcessor(FeatureExtractionMixin): """