import os import torch import numpy as np import gradio as gr import logging import sys from typing import Optional, Literal from pydantic import BaseModel from transformers import pipeline from pyannote.audio import Pipeline from huggingface_hub import HfApi from torchaudio import functional as F # For resampling and audio processing # To run this Gradio demo, first ensure you have Python 3.9+ installed. # Then, create a virtual environment and install the required packages. # # 1. Create and activate a virtual environment (recommended): # python3 -m venv venv # source venv/bin/activate # On Linux/macOS # venv\Scripts\activate # On Windows # # 2. Install the necessary packages: # pip install gradio==4.20.1 \ # torch==2.2.1 \ # torchaudio==2.2.1 \ # transformers==4.38.2 \ # pyannote-audio==3.1.1 \ # numpy==1.26.4 \ # fastapi==0.110.0 \ # uvicorn==0.27.1 \ # python-multipart==0.0.9 \ # pydantic==2.6.1 \ # soundfile==0.12.1 # Required by torchaudio and pyannote for certain audio formats # # # If you have a CUDA-compatible GPU, install the CUDA version of PyTorch instead: # # For CUDA 12.1 (adjust 'cu121' to your CUDA version, e.g., 'cu118' for CUDA 11.8): # # pip install torch==2.2.1 torchaudio==2.2.1 --index-url https://download.pytorch.org/whl/cu121 # # 3. Set your Hugging Face Token (required for pyannote/speaker-diarization-3.1). # You must accept the user conditions on the model page: https://huggingface.co/pyannote/speaker-diarization-3.1 # export HF_TOKEN="hf_YOUR_TOKEN_HERE" # # Or directly in the script (not recommended for security): # # HF_TOKEN = "hf_YOUR_TOKEN_HERE" # # 4. Save this file as, for example, `app.py`. # # 5. Run the application using uvicorn (as requested): # uvicorn app:demo --host 0.0.0.0 --port 7860 # # # If you just want to run it via Python script (Gradio's default, without uvicorn directly): # # python app.py # Set up logging logging.basicConfig(level=logging.INFO, stream=sys.stdout, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # --- Configuration --- # You will need a Hugging Face token for pyannote/speaker-diarization-3.1. # 1. Go to https://huggingface.co/settings/tokens to create a new token. # 2. Make sure you have accepted the user conditions on the model page: # https://huggingface.co/pyannote/speaker-diarization-3.1 # 3. Set your token as an environment variable before running this script: # export HF_TOKEN="hf_YOUR_TOKEN_HERE" # Alternatively, replace os.getenv("HF_TOKEN") with your actual token string: # HF_TOKEN = "hf_YOUR_TOKEN_HERE" HF_TOKEN = os.getenv("HF_TOKEN") # Model names ASR_MODEL = "openai/whisper-large-v3-turbo" # Smaller, faster Whisper model for demo DIARIZATION_MODEL = "pyannote/speaker-diarization-3.1" # Speculative decoding (assistant model) is explicitly excluded as per requirements. # --- Inference Configuration (Pydantic Model for validation) --- class InferenceConfig(BaseModel): task: Literal["transcribe", "translate"] = "transcribe" batch_size: int = 1 chunk_length_s: int = 30 language: Optional[str] = None num_speakers: Optional[int] = None min_speakers: Optional[int] = None max_speakers: Optional[int] = None # --- Global Models and Device --- models = {} device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") logger.info(f"Using device: {device.type}") torch_dtype = torch.float32 if device.type == "cpu" else torch.float16 # Use float16 on GPU for efficiency # --- Model Loading Function --- def load_models(): """ Loads the ASR and Diarization models into the global `models` dictionary. Handles device placement and Hugging Face token authentication. """ logger.info("Loading ASR pipeline...") # The ASR pipeline can directly take a numpy array for inference. models["asr_pipeline"] = pipeline( "automatic-speech-recognition", model=ASR_MODEL, torch_dtype=torch_dtype, device=device ) logger.info("ASR pipeline loaded.") if DIARIZATION_MODEL: logger.info(f"Loading Diarization pipeline: {DIARIZATION_MODEL}...") if not HF_TOKEN: raise ValueError( "HF_TOKEN environment variable or HF_TOKEN constant not set. " "Pyannote models require a Hugging Face token for authentication. " "Get it from https://huggingface.co/settings/tokens and ensure you accept " "the user conditions on the model page: " "https://huggingface.co/pyannote/speaker-diarization-3.1" ) try: # Verify token and load pyannote pipeline HfApi().whoami(token=HF_TOKEN) # Check token validity models["diarization_pipeline"] = Pipeline.from_pretrained( checkpoint_path=DIARIZATION_MODEL, use_auth_token=HF_TOKEN, ) models["diarization_pipeline"].to(device) logger.info("Diarization pipeline loaded.") except Exception as e: logger.error(f"Failed to load diarization pipeline: {e}") raise else: models["diarization_pipeline"] = None logger.info("Diarization model not specified, diarization will be skipped.") # Load models once when the script starts try: load_models() except Exception as e: logger.critical(f"Failed to load models. Please check your HF_TOKEN and model names. Exiting: {e}") sys.exit(1) # --- Diarization Utility Functions (adapted from original `model-server/app/utils/diarization_utils.py`) --- def preprocess_audio_for_diarization(sampling_rate_in: int, audio_array_in: np.ndarray) -> tuple[torch.Tensor, int]: """ Preprocesses audio for the diarization pipeline. Resamples to 16kHz and ensures single channel float32 torch tensor. """ if audio_array_in is None or audio_array_in.size == 0: raise ValueError("Audio array is empty for diarization preprocessing.") # Convert to float32 if not already (pyannote expects float32) if audio_array_in.dtype != np.float32: audio_array_in = audio_array_in.astype(np.float32) # If stereo, take one channel (pyannote expects single channel) if len(audio_array_in.shape) > 1: audio_array_in = audio_array_in[:, 0] # Take the first channel # Resample to 16kHz if necessary, as pyannote models are typically trained on 16kHz audio. if sampling_rate_in != 16000: audio_array_in = F.resample( torch.from_numpy(audio_array_in), sampling_rate_in, 16000 ).numpy() sampling_rate_in = 16000 # Update SR to reflect resampling # Diarization model expects float32 torch tensor of shape `(channels, seq_len)` diarizer_inputs = torch.from_numpy(audio_array_in).float() diarizer_inputs = diarizer_inputs.unsqueeze(0) # Add channel dimension (1, seq_len) return diarizer_inputs, sampling_rate_in def diarize_audio(diarizer_inputs: torch.Tensor, diarization_pipeline: Pipeline, parameters: InferenceConfig) -> list: """ Performs diarization using the pyannote pipeline and combines consecutive speaker segments. """ # Run the diarization pipeline diarization = diarization_pipeline( {"waveform": diarizer_inputs, "sample_rate": 16000}, # Always pass 16kHz to diarizer num_speakers=parameters.num_speakers, min_speakers=parameters.min_speakers, max_speakers=parameters.max_speakers, ) raw_segments = [] # pyannote.audio returns segments as `Segment(start=X, end=Y)` for segment, _, label in diarization.itertracks(yield_label=True): raw_segments.append( { "segment": {"start": segment.start, "end": segment.end}, "label": label, } ) # Combine consecutive segments from the same speaker combined_segments = [] if not raw_segments: return combined_segments # Initialize with the first segment current_speaker_segment = { "speaker": raw_segments[0]["label"], "segment": {"start": raw_segments[0]["segment"]["start"], "end": raw_segments[0]["segment"]["end"]}, } for i in range(1, len(raw_segments)): next_segment = raw_segments[i] # If the speaker changes if next_segment["label"] != current_speaker_segment["speaker"]: # Add the accumulated segment for the previous speaker combined_segments.append(current_speaker_segment) # Start a new segment accumulation with the current speaker current_speaker_segment = { "speaker": next_segment["label"], "segment": {"start": next_segment["segment"]["start"], "end": next_segment["segment"]["end"]}, } else: # Same speaker, extend the end time of the current accumulated segment current_speaker_segment["segment"]["end"] = next_segment["segment"]["end"] # Add the very last accumulated segment after the loop finishes combined_segments.append(current_speaker_segment) return combined_segments def post_process_segments_and_transcripts(combined_diarization_segments: list, asr_transcript_chunks: list) -> list: """ Aligns combined diarization segments with ASR transcript chunks. This logic closely follows the provided `diarization_utils.py`'s `post_process_segments_and_transcripts` function, which uses `argmin` for alignment and slicing for chunk consumption. """ if not asr_transcript_chunks: return [] # Get the end timestamps for each ASR chunk # Use sys.float_info.max for None to ensure `argmin` works asr_end_timestamps = np.array( [chunk["timestamp"][1] if chunk["timestamp"][1] is not None else sys.float_info.max for chunk in asr_transcript_chunks] ) # Create mutable copies to slice from current_asr_chunks = list(asr_transcript_chunks) current_asr_end_timestamps = asr_end_timestamps.copy() final_segmented_transcript = [] for diar_segment in combined_diarization_segments: if not current_asr_chunks: break # No more ASR chunks to process diar_start = diar_segment["segment"]["start"] diar_end = diar_segment["segment"]["end"] speaker = diar_segment["speaker"] # Find the index of the ASR chunk whose end timestamp is closest to diar_end # Ensure argmin operates on a non-empty array if current_asr_end_timestamps.size == 0: logger.warning("No ASR end timestamps left to align with diarization segment. Breaking alignment.") break # No more ASR chunks to align upto_idx_relative = np.argmin(np.abs(current_asr_end_timestamps - diar_end)) chunks_for_this_diar_segment = current_asr_chunks[:upto_idx_relative + 1] if not chunks_for_this_diar_segment: logger.warning(f"No ASR chunks selected for diarization segment [{diar_start:.2f}-{diar_end:.2f}] {speaker}. Skipping.") continue # Initialize with extreme values to find min/max correctly, handling None timestamps asr_min_start_val = float('inf') asr_max_end_val = float('-inf') all_text = [] for chunk in chunks_for_this_diar_segment: all_text.append(chunk["text"]) if chunk["timestamp"] and chunk["timestamp"][0] is not None: asr_min_start_val = min(asr_min_start_val, chunk["timestamp"][0]) if chunk["timestamp"] and chunk["timestamp"][1] is not None: asr_max_end_val = max(asr_max_end_val, chunk["timestamp"][1]) combined_text = "".join(all_text).strip() # If no valid timestamps were found in the selected ASR chunks, fall back to diarization segment's bounds if asr_min_start_val == float('inf'): logger.warning(f"No valid start timestamps in ASR chunks for segment [{diar_start:.2f}-{diar_end:.2f}] {speaker}. Using diarization start.") asr_min_start_val = diar_start if asr_max_end_val == float('-inf'): logger.warning(f"No valid end timestamps in ASR chunks for segment [{diar_start:.2f}-{diar_end:.2f}] {speaker}. Using diarization end.") asr_max_end_val = diar_end # Ensure final timestamp range makes sense and is clamped by diarization segment final_segment_start = max(diar_start, asr_min_start_val) final_segment_end = min(diar_end, asr_max_end_val) final_segmented_transcript.append( { "speaker": speaker, "text": combined_text, "timestamp": (final_segment_start, final_segment_end), } ) # Crop the transcripts and timestamp lists according to the latest timestamp current_asr_chunks = current_asr_chunks[upto_idx_relative + 1:] current_asr_end_timestamps = current_asr_end_timestamps[upto_idx_relative + 1:] return final_segmented_transcript def diarize_and_align_transcript(diarization_pipeline: Pipeline, original_sampling_rate: int, audio_numpy_array: np.ndarray, parameters: InferenceConfig, asr_outputs: dict) -> list: """ Orchestrates the entire diarization and transcript alignment process. """ # 1. Preprocess audio for the diarization model (resample to 16kHz, ensure mono, convert to torch.Tensor) diarizer_input_tensor, processed_sampling_rate = preprocess_audio_for_diarization( original_sampling_rate, audio_numpy_array ) # 2. Perform diarization to get speaker segments # Update parameters with the processed sampling rate for diarization model's internal use. diarization_params_for_pipeline = parameters.model_copy(update={"sampling_rate": processed_sampling_rate}) combined_diarization_segments = diarize_audio( diarizer_input_tensor, diarization_pipeline, diarization_params_for_pipeline ) # 3. Align diarization segments with ASR transcript chunks aligned_transcript = post_process_segments_and_transcripts( combined_diarization_segments, asr_outputs["chunks"] ) return aligned_transcript # --- Main Prediction Function for Gradio Interface --- def predict_audio( audio_file_tuple: tuple[int, np.ndarray], batch_size: int, chunk_length_s: int, language: str, num_speakers: Optional[int], min_speakers: Optional[int], max_speakers: Optional[int] ) -> tuple[str, str, str]: """ Gradio-compatible function to perform ASR and optionally speaker diarization. Args: audio_file_tuple: A tuple (sampling_rate, numpy_array) from Gradio's gr.Audio input. batch_size: Batch size for ASR inference. chunk_length_s: Chunk length for ASR inference in seconds. language: Language for ASR (e.g., "English", "Auto-detect"). num_speakers: Expected number of speakers for diarization (optional). min_speakers: Minimum number of speakers for diarization (optional). max_speakers: Maximum number of speakers for diarization (optional). Returns: A tuple containing: - formatted_diarized_text: A string with the diarized transcript. - full_transcript_text: A string with the full ASR transcript. - status_message: A message indicating success or failure. """ if audio_file_tuple is None: return "", "", gr.Warning("Please upload an audio file.") sampling_rate, audio_numpy_array = audio_file_tuple if audio_numpy_array is None or audio_numpy_array.size == 0: return "", "", gr.Warning("Audio file is empty. Please upload a valid audio.") # Ensure audio_numpy_array is float32 as expected by transformers pipeline if audio_numpy_array.dtype != np.float32: audio_numpy_array = audio_numpy_array.astype(np.float32) # If stereo, convert to mono for consistent processing (e.g., take the first channel) if len(audio_numpy_array.shape) > 1: audio_numpy_array = audio_numpy_array[:, 0] # Process speaker parameters: convert 0 or negative values to None for pyannote compatibility processed_num_speakers = num_speakers if num_speakers is not None and num_speakers > 0 else None processed_min_speakers = min_speakers if min_speakers is not None and min_speakers > 0 else None processed_max_speakers = max_speakers if max_speakers is not None and max_speakers > 0 else None # Validation logic for min/max speakers if processed_min_speakers is not None and processed_max_speakers is not None and processed_min_speakers > processed_max_speakers: return "", "", gr.Warning("Diarization: Min Speakers cannot be greater than Max Speakers.") if processed_num_speakers is not None: if processed_min_speakers is not None and processed_num_speakers < processed_min_speakers: return "", "", gr.Warning("Diarization: Number of Speakers cannot be less than Min Speakers.") if processed_max_speakers is not None and processed_num_speakers > processed_max_speakers: return "", "", gr.Warning("Diarization: Number of Speakers cannot be greater than Max Speakers.") # Create an InferenceConfig object from Gradio inputs for internal validation and use. try: parameters = InferenceConfig( batch_size=batch_size, chunk_length_s=chunk_length_s, language=language if language != "Auto-detect" else None, # Convert "Auto-detect" to None for model num_speakers=processed_num_speakers, min_speakers=processed_min_speakers, max_speakers=processed_max_speakers, ) except Exception as e: logger.error(f"Error validating parameters: {e}") return "", "", gr.Error(f"Error validating input parameters: {e}") # Use gr.Error for critical validation failures logger.info(f"Inference parameters: {parameters.model_dump_json()}") logger.info(f"Audio sampling rate: {sampling_rate} Hz, Audio shape: {audio_numpy_array.shape}") asr_pipeline = models.get("asr_pipeline") diarization_pipeline = models.get("diarization_pipeline") if not asr_pipeline: return "", "", gr.Error("ASR model not loaded. Please restart the application.") # ASR language and batch size conflict warning/error if parameters.language is None and parameters.batch_size > 1: return "", "", gr.Warning( "ASR: 'Auto-detect' language is not supported with batch size > 1. " "Please select a specific language or set batch size to 1." ) # Prepare ASR generation arguments generate_kwargs = { "task": parameters.task, "language": parameters.language, "assistant_model": None # Speculative decoding is disabled } asr_outputs = None try: logger.info("Starting ASR inference...") asr_outputs = asr_pipeline( audio_numpy_array, # Pass numpy array directly chunk_length_s=parameters.chunk_length_s, batch_size=parameters.batch_size, generate_kwargs=generate_kwargs, return_timestamps=True, # sampling_rate=sampling_rate # Pass original sampling rate to pipeline ) logger.info("ASR inference completed.") except Exception as e: logger.error(f"ASR inference error: {str(e)}") return "", "", gr.Error(f"ASR inference error: {str(e)}") final_transcript_data = [] status_message = "" if diarization_pipeline: try: logger.info("Starting Diarization inference and alignment...") final_transcript_data = diarize_and_align_transcript( diarization_pipeline, sampling_rate, audio_numpy_array, parameters, asr_outputs ) status_message = "Diarization and ASR successful!" logger.info("Diarization and alignment completed.") except Exception as e: logger.error(f"Diarization inference error: {str(e)}") # If diarization fails, still provide the full ASR transcript final_transcript_data = [] # Clear any partial diarization status_message = f"Diarization failed: {str(e)}. Displaying full ASR transcript only." else: logger.info("Diarization pipeline not loaded, skipping diarization and returning raw ASR chunks.") # If no diarization, format ASR chunks as if they were from a single "Speaker" for chunk in asr_outputs["chunks"]: final_transcript_data.append({ "speaker": "Speaker", # Generic label "text": chunk["text"], "timestamp": chunk["timestamp"] }) status_message = "Diarization not enabled. Displaying full ASR transcript by chunk." # Format the output for Gradio display formatted_diarized_text_output = [] for entry in final_transcript_data: start_time = f"{entry['timestamp'][0]:.2f}" if entry['timestamp'][0] is not None else "0.00" end_time = f"{entry['timestamp'][1]:.2f}" if entry['timestamp'][1] is not None else "End" formatted_diarized_text_output.append( f"[{start_time} - {end_time}] {entry['speaker']}: {entry['text'].strip()}" ) full_asr_text_output = asr_outputs["text"] if asr_outputs else "No ASR transcript generated." return ( "\n".join(formatted_diarized_text_output), full_asr_text_output, status_message ) # --- Gradio Interface Definition --- # List of languages supported by OpenAI Whisper models WHISPER_LANGUAGES = [ "Auto-detect", "English", "Chinese", "German", "Spanish", "Russian", "Korean", "French", "Japanese", "Portuguese", "Turkish", "Polish", "Catalan", "Dutch", "Arabic", "Swedish", "Italian", "Indonesian", "Hindi", "Finnish", "Vietnamese", "Hebrew", "Ukrainian", "Greek", "Malay", "Czech", "Romanian", "Danish", "Hungarian", "Tamil", "Norwegian", "Thai", "Urdu", "Croatian", "Bulgarian", "Lithuanian", "Latin", "Maori", "Malayalam", "Afrikaans", "Welsh", "Belarusian", "Gujarati", "Kannada", "Armenian", "Azerbaijani", "Serbian", "Slovenian", "Estonian", "Burmese", "Galician", "Mongolian", "Lao", "Kazakh", "Georgian", "Amharic", "Nepali", "Bosnian", "Luxembourgish", "Pashto", "Tagalog", "Malagasy", "Albanian", "Sindhi", "Kurdish", "Somali", "Telugu", "Tajik", "Swahili", "Kashmiri" ] demo = gr.Interface( fn=predict_audio, inputs=[ gr.Audio(type="numpy", label="Upload Audio File (WAV, MP3, FLAC, etc.)"), gr.Slider(minimum=1, maximum=32, value=1, step=1, label="ASR Batch Size"), gr.Slider(minimum=1, maximum=30, value=30, step=1, label="ASR Chunk Length (seconds)"), gr.Dropdown(WHISPER_LANGUAGES, value="Chinese", label="ASR Language"), gr.Number(label="Diarization: Number of Speakers (optional)", value=None, precision=0, info="Expected total number of speakers (positive integer, or leave empty for auto-detect)."), gr.Number(label="Diarization: Min Speakers (optional)", value=None, precision=0, info="Minimum number of speakers to detect (positive integer, or leave empty for auto-detect)."), gr.Number(label="Diarization: Max Speakers (optional)", value=None, precision=0, info="Maximum number of speakers to detect (positive integer, or leave empty for auto-detect).") ], outputs=[ gr.Textbox(label="Diarized Transcript", lines=10, interactive=False), gr.Textbox(label="Full ASR Transcript", lines=5, interactive=False), gr.Textbox(label="Status Message", lines=1, interactive=False) ], title="Whisper ASR with Pyannote Speaker Diarization", description=( "Upload an audio file to get a transcript with speaker diarization. " "This demo uses `openai/whisper-small` for ASR and `pyannote/speaker-diarization-3.1` for diarization. " "A Hugging Face token with access to `pyannote/speaker-diarization-3.1` is required. " "Please set it as an `HF_TOKEN` environment variable before launching (see script comments)." "
Note: For long audios or high concurrent usage, consider using a GPU and models like `whisper-large-v3`." ), allow_flagging="never", # Disable Gradio flagging feature examples=[ # Adjust this path if the `model-server/app/tests/` directory is not alongside your `app.py` # For example, if app.py is in the root, and the audio is in a tests/ subdirectory, # you might use: ["tests/polyai-minds14-0.wav", 24, 30, "Auto-detect", None, None, None] [os.path.join(os.path.dirname(__file__), "model-server", "app", "tests", "polyai-minds14-0.wav"), 24, 30, "Auto-detect", None, None, None] ], cache_examples=False # Disable caching of examples to prevent InvalidPathError ) if __name__ == "__main__": logger.info("Starting Gradio demo...") demo.launch()