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()