""" SmartFile Speaker Diarization — Hugging Face Space ================================================== Runs pyannote/speaker-diarization-3.1 to detect WHO SPEAKS WHEN in an audio clip. SmartFile calls this BEFORE transcription so each speaker turn can be transcribed and labeled (Locuteur 1, Locuteur 2, ...). Contract (same simple pattern as the STT Space): INPUT : base64-encoded audio (WAV/etc) in a Textbox. OUTPUT : a JSON string -> {"segments": [{"speaker": "...", "start": s, "end": s}, ...], "num_speakers": N} or {"error": "..."} on failure. The pipeline is GATED on Hugging Face and requires accepting terms for BOTH: - pyannote/speaker-diarization-3.1 - pyannote/segmentation-3.0 (dependency) and an HF_TOKEN secret on this Space with access to them. Tuning: this client's audio is mostly 2-person interviews/calls, so we hint the pipeline toward 2 speakers (min 1, max 4) — detected, not hard-forced, so a stray third voice won't break it. Tiny adjacent same-speaker turns are merged. NOTE: pyannote loads several models (segmentation + embedding); it is HEAVIER than the STT Space. Use a paid CPU (or GPU) tier for usable speed. """ import base64 import io import json import os import gradio as gr import torch from pyannote.audio import Pipeline HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") MODEL_ID = "pyannote/speaker-diarization-3.1" # Hint for the typical case (2-person interview/call). Detected, not hard-forced. DEFAULT_MIN_SPEAKERS = 1 DEFAULT_MAX_SPEAKERS = 4 MERGE_GAP_SEC = 0.5 # merge same-speaker segments separated by < this gap MIN_SEG_SEC = 0.3 # drop micro-segments shorter than this (noise/clicks) print(f"[diar] loading {MODEL_ID} ...") pipeline = Pipeline.from_pretrained(MODEL_ID, token=HF_TOKEN) if torch.cuda.is_available(): pipeline.to(torch.device("cuda")) print("[diar] using GPU") print("[diar] pipeline ready") def _merge_segments(segs): """Merge adjacent same-speaker segments with tiny gaps; drop micro-segments.""" if not segs: return segs segs = sorted(segs, key=lambda s: s["start"]) merged = [segs[0]] for s in segs[1:]: last = merged[-1] if s["speaker"] == last["speaker"] and s["start"] - last["end"] <= MERGE_GAP_SEC: last["end"] = max(last["end"], s["end"]) else: merged.append(s) return [s for s in merged if (s["end"] - s["start"]) >= MIN_SEG_SEC] def diarize_b64(audio_b64): if not audio_b64: return json.dumps({"error": "empty input"}) try: if audio_b64.strip().startswith("data:") and "," in audio_b64[:64]: audio_b64 = audio_b64.split(",", 1)[1] raw = base64.b64decode(audio_b64) print(f"[diar] decoded {len(raw)} audio bytes") # pyannote reads a file path or a waveform; easiest robust path is to # write the bytes to a temp file (it handles WAV/MP3/etc + resampling). import tempfile with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: tmp.write(raw) tmp_path = tmp.name diarization = pipeline( tmp_path, min_speakers=DEFAULT_MIN_SPEAKERS, max_speakers=DEFAULT_MAX_SPEAKERS, ) os.unlink(tmp_path) segs = [] # pyannote 4.x returns an object whose `.speaker_diarization` yields # (turn, speaker) pairs. pyannote 3.x returns an Annotation with # `.itertracks(yield_label=True)` yielding (turn, _, speaker). Support # both so the Space isn't tied to one pyannote version. if hasattr(diarization, "speaker_diarization"): for turn, speaker in diarization.speaker_diarization: segs.append({ "speaker": str(speaker), "start": round(float(turn.start), 2), "end": round(float(turn.end), 2), }) else: for turn, _, speaker in diarization.itertracks(yield_label=True): segs.append({ "speaker": str(speaker), # e.g. "SPEAKER_00" "start": round(float(turn.start), 2), "end": round(float(turn.end), 2), }) segs = _merge_segments(segs) speakers = sorted({s["speaker"] for s in segs}) print(f"[diar] {len(segs)} segments, {len(speakers)} speakers") return json.dumps({"segments": segs, "num_speakers": len(speakers)}) except Exception as e: print(f"[diar] error: {e}") return json.dumps({"error": str(e)}) demo = gr.Interface( fn=diarize_b64, inputs=gr.Textbox(label="Base64 audio"), outputs=gr.Textbox(label="Diarization JSON"), title="SmartFile Speaker Diarization", description="Detect who speaks when (pyannote 3.1). Send base64 audio, get speaker segments as JSON.", ) if __name__ == "__main__": demo.launch()