Spaces:
Running
Running
File size: 8,305 Bytes
e1fb785 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | """Speaker verification with ECAPA-TDNN trained on VoxCeleb 1+2.
Published as https://huggingface.co/spaces/espnet/speaker-verification; the
source lives in espnet, at egs2/voxceleb/spk1/demo. The recipe that trained
the checkpoint is the directory above this one.
One of the five demo Spaces this repository maintains; the others are
egs2/owsm_ctc_v4/s2t1/demo, egs2/owsm_v4/s2t1/demo, egs2/ljspeech/tts1/demo
and egs2/universal_se_v1/enh1/demo.
"""
# The ZeroGPU package patches torch as it is imported, so it has to come
# first - before torch, and before anything that imports torch.
try: # only Hugging Face's runners have it
import spaces # isort: skip
except ImportError: # running elsewhere: the decorator does nothing
class spaces: # noqa: N801 - stands in for the module
@staticmethod
def GPU(func=None, **kwargs):
return func if func is not None else (lambda f: f)
import os # noqa: E402
import gradio as gr # noqa: E402
import librosa # noqa: E402
import torch # noqa: E402
from espnet2.bin.spk_inference import Speech2Embedding # noqa: E402
# Two recordings per call, each capped, and one forward pass over each: the
# model is small and the slice below is mostly the cold start. Longer audio
# is trimmed rather than refused.
MAX_SECS = 30
GPU_SECONDS = 60
MODEL_TAG = os.environ.get("SPK_MODEL_TAG", "espnet/voxcelebs12_rawnet3")
# ZeroGPU attaches the GPU only while a @spaces.GPU function runs, so
# torch.cuda.is_available() is False here and asking it would pin the models to
# the CPU on the very hardware bought to run them. SPACES_ZERO_GPU is the
# runtime's own marker; `spaces` being importable is not, since anyone can
# install it.
ZERO_GPU = bool(os.environ.get("SPACES_ZERO_GPU"))
if os.environ.get("DEVICE"):
DEVICE = os.environ["DEVICE"]
elif ZERO_GPU or torch.cuda.is_available():
DEVICE = "cuda"
else:
DEVICE = "cpu"
# egs2/voxceleb/spk1/README.md, this checkpoint on VoxCeleb1-O: target trials
# score -0.8091 +/- 0.1398 at an EER of 0.978%. The recipe scores a trial as
# minus the Euclidean distance between L2-normalised embeddings, so a score s
# is a cosine of 1 - s^2 / 2. Reading the target scores as normal, the equal
# error point sits 2.33 standard deviations below their mean: a distance of
# 1.135, which is a cosine of 0.36. That is where the slider starts. It is
# one operating point measured on one corpus of read English speech, not a
# constant of nature, which is why it is a slider and not a constant.
# Not something a checkpoint records, and not something the recipe records
# either: its table gives each model's equal error *rate*, not the score at
# which that rate occurs. A threshold is a choice about which errors to make,
# so 0.36 is a place to start moving the slider from rather than a
# measurement, and SPK_THRESHOLD sets that starting value for anyone who has
# measured one for their checkpoint.
THRESHOLD = float(os.environ.get("SPK_THRESHOLD", "0.36"))
FIRST_WAV = "https://github.com/espnet/espnet/raw/master/test_utils/ctc_align_test.wav"
SECOND_WAV = "https://github.com/espnet/espnet/raw/master/test_utils/st_test.wav"
THRESHOLD_INFO = (
f"{THRESHOLD} is a place to start, not a measured operating point; "
"set SPK_THRESHOLD if you have one"
)
TITLE = "Speaker verification"
DESCRIPTION = """# Speaker verification
RawNet3 turns a recording into one vector that describes the voice rather
than the words, reading the waveform itself instead of a spectrogram. Two
recordings of the same person land close together, two people land far
apart, and the cosine between the vectors is the score. This checkpoint was
trained on VoxCeleb 1+2 with [ESPnet](https://github.com/espnet/espnet), by
the recipe at
[`egs2/voxceleb/spk1`](https://github.com/espnet/espnet/tree/master/egs2/voxceleb/spk1),
where it reaches 0.74% equal error rate on VoxCeleb1-O.
Give it two recordings of speech. The verdict is the score against the
threshold, and the threshold is yours to move.
"""
ARTICLE = """Model:
[`espnet/voxcelebs12_rawnet3`](https://huggingface.co/espnet/voxcelebs12_rawnet3).
Source of this Space:
[`egs2/voxceleb/spk1/demo`](https://github.com/espnet/espnet/tree/master/egs2/voxceleb/spk1/demo).
The score means something only for speech: two recordings of anything else
land wherever the model happens to put them. A demo is also not an
authentication system - a recording of a voice scores like the voice.
```bibtex
@article{jung2024espnet,
title={{ESPnet-SPK}: full pipeline speaker embedding toolkit with
reproducible recipes, self-supervised front-ends, and off-the-shelf
models},
author={Jung, Jee-weon and Zhang, Wangyou and Shi, Jiatong and
Aldeneh, Zakaria and Higuchi, Takuya and Theobald, Barry-John and
Abdelaziz, Ahmed Hussen and Watanabe, Shinji},
journal={Proc. Interspeech 2024},
year={2024}
}
```"""
spk = Speech2Embedding.from_pretrained(MODEL_TAG, device=DEVICE)
# Both numbers come from the checkpoint rather than from this file: the rate
# it expects, and the length of the crops it was trained to score, which is
# what makes a much shorter recording worth a warning.
PREPROCESSOR = getattr(spk.spk_train_args, "preprocessor_conf", None) or {}
SAMPLE_RATE = int(PREPROCESSOR.get("sample_rate", 16000))
TRAINED_SECS = float(PREPROCESSOR.get("target_duration", 3.0))
def _read(path, which):
"""One recording at the model's rate, trimmed to the cap."""
if path is None:
raise gr.Error(f"Record or upload the {which} recording too.")
# The header says how long the file is, so only the part that is scored
# is decoded: an hour of audio uploaded to a 30 s demo is not resampled
# in full first, and the warning is still about the file rather than
# about what came back.
if librosa.get_duration(path=path) > MAX_SECS:
gr.Warning(
f"Only the first {MAX_SECS} s of the {which} recording were used. "
"Run the app yourself for longer audio - the model has no such "
"limit."
)
speech, _ = librosa.load(path, sr=SAMPLE_RATE, duration=MAX_SECS)
if len(speech) < SAMPLE_RATE * TRAINED_SECS:
gr.Warning(
f"The {which} recording is under the {TRAINED_SECS:.0f} s this "
"model was trained to score, so the similarity will be a noisy "
"estimate."
)
return speech
@spaces.GPU(duration=GPU_SECONDS)
def predict(first_path, second_path, threshold):
first = spk(_read(first_path, "first"))
second = spk(_read(second_path, "second"))
# The embeddings are compared the way the recipe compares them, which is
# a cosine: on L2-normalised vectors its score is a monotone function of
# this one, so the threshold above carries over.
similarity = float(torch.nn.functional.cosine_similarity(first, second).item())
verdict = "Same speaker" if similarity >= threshold else "Different speakers"
return round(similarity, 3), f"{verdict}, at a threshold of {threshold:.2f}"
EXAMPLES = [[FIRST_WAV, SECOND_WAV, THRESHOLD]]
with gr.Blocks(title=TITLE) as demo:
gr.Markdown(DESCRIPTION)
with gr.Row():
with gr.Column():
first = gr.Audio(
sources=["microphone", "upload"],
type="filepath",
label="First recording",
)
second = gr.Audio(
sources=["microphone", "upload"],
type="filepath",
label="Second recording",
)
threshold = gr.Slider(
-1.0,
1.0,
value=THRESHOLD,
step=0.01,
label="Threshold",
info=THRESHOLD_INFO,
)
button = gr.Button("Compare", variant="primary")
with gr.Column():
similarity = gr.Number(label="Cosine similarity")
verdict = gr.Textbox(label="Verdict")
button.click(predict, [first, second, threshold], [similarity, verdict])
gr.Examples(
EXAMPLES,
inputs=[first, second, threshold],
outputs=[similarity, verdict],
fn=predict,
cache_examples=False,
)
gr.Markdown(ARTICLE)
if __name__ == "__main__":
demo.launch()
|