audio: sliding-window speaker embeddings (window/step)

/v1/audio/speaker-embeddings gains window (seconds) + step (default = window):
returns one embedding per sliding window, each tagged start/end, instead of a
single whole-file embedding. Implemented in the pyannote worker's /embed via
_crop_wav over the cached embedder; client + endpoint pass the params through;
whole-file mode and enroll/identify/verify are unchanged.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6iugoNeEgt9PkajKrFyq4
parent 7e5a925c
...@@ -29,8 +29,12 @@ from typing import Optional ...@@ -29,8 +29,12 @@ from typing import Optional
def get_speaker_embedding(audio_path: str, backend: str = "ecapa", def get_speaker_embedding(audio_path: str, backend: str = "ecapa",
model: Optional[str] = None, model: Optional[str] = None,
config: Optional[dict] = None) -> dict: config: Optional[dict] = None,
"""Return ``{"embedding":[...], "dim":N, "backend":..., "model":...}``.""" window: Optional[float] = None,
step: Optional[float] = None) -> dict:
"""Return ``{"embedding":[...], "dim":N, "backend":..., "model":...}``, or —
when ``window`` (seconds) is given — ``{"windows":[{start,end,embedding}], ...}``
with one embedding per sliding window (hop ``step``, default = window)."""
import requests import requests
from codai.api.diarization import _get_diarizer from codai.api.diarization import _get_diarizer
# _get_diarizer ensures the (VRAM-tracked, self-healing) pyannote worker is up # _get_diarizer ensures the (VRAM-tracked, self-healing) pyannote worker is up
...@@ -39,6 +43,10 @@ def get_speaker_embedding(audio_path: str, backend: str = "ecapa", ...@@ -39,6 +43,10 @@ def get_speaker_embedding(audio_path: str, backend: str = "ecapa",
params = {"backend": backend or "ecapa"} params = {"backend": backend or "ecapa"}
if model: if model:
params["model"] = model params["model"] = model
if window and float(window) > 0:
params["window"] = float(window)
if step and float(step) > 0:
params["step"] = float(step)
with open(audio_path, "rb") as f: with open(audio_path, "rb") as f:
resp = requests.post(f"{handle.url}/embed", params=params, data=f.read(), resp = requests.post(f"{handle.url}/embed", params=params, data=f.read(),
timeout=600, headers={"Content-Type": "application/octet-stream"}) timeout=600, headers={"Content-Type": "application/octet-stream"})
......
...@@ -293,13 +293,19 @@ async def create_speaker_embedding( ...@@ -293,13 +293,19 @@ async def create_speaker_embedding(
file: UploadFile = File(...), file: UploadFile = File(...),
model: Optional[str] = Form(None), model: Optional[str] = Form(None),
backend: Optional[str] = Form("ecapa"), backend: Optional[str] = Form("ecapa"),
window: Optional[float] = Form(None),
step: Optional[float] = Form(None),
): ):
"""Speaker-embedding (voiceprint) endpoint. """Speaker-embedding (voiceprint) endpoint.
Returns a fixed-dim embedding for the speaker in the audio, usable for Returns a fixed-dim embedding for the speaker in the audio, usable for
verification / clustering. ``backend``: ``ecapa`` (speechbrain ECAPA-TDNN, verification / clustering. ``backend``: ``ecapa`` (speechbrain ECAPA-TDNN,
ungated, default), ``pyannote`` / ``wespeaker`` (pyannote embedding models, ungated, default), ``pyannote`` / ``wespeaker`` (pyannote embedding models,
may need HF_TOKEN). ``model`` overrides the backend's default checkpoint.""" may need HF_TOKEN). ``model`` overrides the backend's default checkpoint.
``window`` (seconds) switches to sliding-window mode: one embedding per window
of that length, hop ``step`` (default = window), each tagged with start/end —
instead of a single whole-file embedding."""
file_content = await file.read() file_content = await file.read()
if len(file_content) > _MAX_AUDIO_BYTES: if len(file_content) > _MAX_AUDIO_BYTES:
raise HTTPException(status_code=413, detail="Audio file too large (max 100 MB)") raise HTTPException(status_code=413, detail="Audio file too large (max 100 MB)")
...@@ -314,8 +320,24 @@ async def create_speaker_embedding( ...@@ -314,8 +320,24 @@ async def create_speaker_embedding(
try: try:
import codai.api.speaker_embeddings as spk import codai.api.speaker_embeddings as spk
result = await asyncio.to_thread( result = await asyncio.to_thread(
spk.get_speaker_embedding, tmp_path, backend or "ecapa", model) spk.get_speaker_embedding, tmp_path, backend or "ecapa", model, None,
window, step)
task_registry.finish(_tid, "done") task_registry.finish(_tid, "done")
if result.get("windows") is not None:
# Sliding-window mode: one embedding per window, each with its span.
return {
"object": "list",
"model": result.get("model"),
"backend": result.get("backend"),
"dim": result.get("dim"),
"window": result.get("window"),
"step": result.get("step"),
"count": result.get("count"),
"data": [{"object": "embedding", "index": i,
"start": w.get("start"), "end": w.get("end"),
"embedding": w.get("embedding")}
for i, w in enumerate(result.get("windows") or [])],
}
return { return {
"object": "list", "object": "list",
"model": result.get("model"), "model": result.get("model"),
......
...@@ -175,11 +175,17 @@ def _hf_token(): ...@@ -175,11 +175,17 @@ def _hf_token():
or os.environ.get("HUGGING_FACE_HUB_TOKEN")) or os.environ.get("HUGGING_FACE_HUB_TOKEN"))
def _speaker_embed(wav_path: str, backend: str, model: str = None) -> dict: def _speaker_embed(wav_path: str, backend: str, model: str = None,
window: float = None, step: float = None) -> dict:
"""Return a fixed-dim speaker embedding for the audio. """Return a fixed-dim speaker embedding for the audio.
Backends: ``ecapa`` (speechbrain ECAPA-TDNN, ungated), ``pyannote`` / Backends: ``ecapa`` (speechbrain ECAPA-TDNN, ungated), ``pyannote`` /
``wespeaker`` (pyannote.audio embedding models — may be gated, use HF_TOKEN).""" ``wespeaker`` (pyannote.audio embedding models — may be gated, use HF_TOKEN).
If ``window`` (seconds) is given, slide a window of that length with hop
``step`` (default = window) over the audio and return one embedding per
window as ``windows``: [{start, end, embedding}], instead of a single
whole-file ``embedding``."""
import torch import torch
backend = (backend or "ecapa").lower() backend = (backend or "ecapa").lower()
key = f"{backend}:{model or 'default'}" key = f"{backend}:{model or 'default'}"
...@@ -219,9 +225,35 @@ def _speaker_embed(wav_path: str, backend: str, model: str = None) -> dict: ...@@ -219,9 +225,35 @@ def _speaker_embed(wav_path: str, backend: str, model: str = None) -> dict:
else: else:
raise ValueError(f"unknown speaker-embedding backend: {backend}") raise ValueError(f"unknown speaker-embedding backend: {backend}")
_EMB_CACHE[key] = fn _EMB_CACHE[key] = fn
_model_out = model or key.split(":", 1)[1]
if window and float(window) > 0:
import soundfile as sf
win = float(window)
hop = float(step) if step and float(step) > 0 else win
dur = float(sf.info(wav_path).duration)
windows = []
t = 0.0
while t < dur:
end = min(t + win, dur)
crop = _crop_wav(wav_path, t, end)
try:
emb = fn(crop)
windows.append({"start": round(t, 3), "end": round(end, 3),
"embedding": emb})
finally:
try:
os.unlink(crop)
except OSError:
pass
if end >= dur:
break
t += hop
dim = len(windows[0]["embedding"]) if windows else 0
return {"windows": windows, "count": len(windows), "dim": dim,
"window": win, "step": hop, "backend": backend, "model": _model_out}
emb = fn(wav_path) emb = fn(wav_path)
return {"embedding": emb, "dim": len(emb), "backend": backend, return {"embedding": emb, "dim": len(emb), "backend": backend,
"model": model or key.split(":", 1)[1]} "model": _model_out}
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
...@@ -260,7 +292,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -260,7 +292,8 @@ class Handler(BaseHTTPRequestHandler):
return v[0] if v else None return v[0] if v else None
wav = _to_wav_16k_mono(raw) wav = _to_wav_16k_mono(raw)
if parsed.path == "/embed": if parsed.path == "/embed":
result = _speaker_embed(wav, backend=_q("backend"), model=_q("model")) result = _speaker_embed(wav, backend=_q("backend"), model=_q("model"),
window=_q("window"), step=_q("step"))
self._send(200, json.dumps(result).encode()) self._send(200, json.dumps(result).encode())
return return
result = ENGINE.diarize(wav, num_speakers=_q("num_speakers"), result = ENGINE.diarize(wav, num_speakers=_q("num_speakers"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment