yum-archive/yapBox

A black box for your yapping

git clone https://git.yummers.dev/yum-archive/yapBox

yumInitial commitca55539

master
11.4 KiB314 linesraw
1# MIT License
2# 
3# Copyright (c) 2023 Guillaume Klein
4# 
5# Permission is hereby granted, free of charge, to any person obtaining a copy
6# of this software and associated documentation files (the "Software"), to deal
7# in the Software without restriction, including without limitation the rights
8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9# copies of the Software, and to permit persons to whom the Software is
10# furnished to do so, subject to the following conditions:
11# 
12# The above copyright notice and this permission notice shall be included in all
13# copies or substantial portions of the Software.
14# 
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21# SOFTWARE.
22
23import bisect
24import functools
25import os
26import warnings
27
28from typing import List, NamedTuple, Optional
29
30import numpy as np
31
32
33# The code below is adapted from https://github.com/snakers4/silero-vad.
34class VadOptions(NamedTuple):
35    """VAD options.
36
37    Attributes:
38      threshold: Speech threshold. Silero VAD outputs speech probabilities for each audio chunk,
39        probabilities ABOVE this value are considered as SPEECH. It is better to tune this
40        parameter for each dataset separately, but "lazy" 0.5 is pretty good for most datasets.
41      min_speech_duration_ms: Final speech chunks shorter min_speech_duration_ms are thrown out.
42      max_speech_duration_s: Maximum duration of speech chunks in seconds. Chunks longer
43        than max_speech_duration_s will be split at the timestamp of the last silence that
44        lasts more than 100ms (if any), to prevent aggressive cutting. Otherwise, they will be
45        split aggressively just before max_speech_duration_s.
46      min_silence_duration_ms: In the end of each speech chunk wait for min_silence_duration_ms
47        before separating it
48      window_size_samples: Audio chunks of window_size_samples size are fed to the silero VAD model.
49        WARNING! Silero VAD models were trained using 512, 1024, 1536 samples for 16000 sample rate.
50        Values other than these may affect model performance!!
51      speech_pad_ms: Final speech chunks are padded by speech_pad_ms each side
52    """
53
54    threshold: float = 0.5
55    min_speech_duration_ms: int = 250
56    max_speech_duration_s: float = float("inf")
57    min_silence_duration_ms: int = 2000
58    window_size_samples: int = 1024
59    speech_pad_ms: int = 400
60
61
62def get_speech_timestamps(
63    audio: np.ndarray,
64    vad_options: Optional[VadOptions] = None,
65    **kwargs,
66) -> List[dict]:
67    """This method is used for splitting long audios into speech chunks using silero VAD.
68
69    Args:
70      audio: One dimensional float array.
71      vad_options: Options for VAD processing.
72      kwargs: VAD options passed as keyword arguments for backward compatibility.
73
74    Returns:
75      List of dicts containing begin and end samples of each speech chunk.
76    """
77    if vad_options is None:
78        vad_options = VadOptions(**kwargs)
79
80    threshold = vad_options.threshold
81    min_speech_duration_ms = vad_options.min_speech_duration_ms
82    max_speech_duration_s = vad_options.max_speech_duration_s
83    min_silence_duration_ms = vad_options.min_silence_duration_ms
84    window_size_samples = vad_options.window_size_samples
85    speech_pad_ms = vad_options.speech_pad_ms
86
87    if window_size_samples not in [512, 1024, 1536]:
88        warnings.warn(
89            "Unusual window_size_samples! Supported window_size_samples:\n"
90            " - [512, 1024, 1536] for 16000 sampling_rate"
91        )
92
93    sampling_rate = 16000
94    min_speech_samples = sampling_rate * min_speech_duration_ms / 1000
95    speech_pad_samples = sampling_rate * speech_pad_ms / 1000
96    max_speech_samples = (
97        sampling_rate * max_speech_duration_s
98        - window_size_samples
99        - 2 * speech_pad_samples
100    )
101    min_silence_samples = sampling_rate * min_silence_duration_ms / 1000
102    min_silence_samples_at_max_speech = sampling_rate * 98 / 1000
103
104    audio_length_samples = len(audio)
105
106    model = get_vad_model()
107    state = model.get_initial_state(batch_size=1)
108
109    speech_probs = []
110    for current_start_sample in range(0, audio_length_samples, window_size_samples):
111        chunk = audio[current_start_sample : current_start_sample + window_size_samples]
112        if len(chunk) < window_size_samples:
113            chunk = np.pad(chunk, (0, int(window_size_samples - len(chunk))))
114        speech_prob, state = model(chunk, state, sampling_rate)
115        speech_probs.append(speech_prob)
116
117    triggered = False
118    speeches = []
119    current_speech = {}
120    neg_threshold = threshold - 0.15
121
122    # to save potential segment end (and tolerate some silence)
123    temp_end = 0
124    # to save potential segment limits in case of maximum segment size reached
125    prev_end = next_start = 0
126
127    for i, speech_prob in enumerate(speech_probs):
128        if (speech_prob >= threshold) and temp_end:
129            temp_end = 0
130            if next_start < prev_end:
131                next_start = window_size_samples * i
132
133        if (speech_prob >= threshold) and not triggered:
134            triggered = True
135            current_speech["start"] = window_size_samples * i
136            continue
137
138        if (
139            triggered
140            and (window_size_samples * i) - current_speech["start"] > max_speech_samples
141        ):
142            if prev_end:
143                current_speech["end"] = prev_end
144                speeches.append(current_speech)
145                current_speech = {}
146                # previously reached silence (< neg_thres) and is still not speech (< thres)
147                if next_start < prev_end:
148                    triggered = False
149                else:
150                    current_speech["start"] = next_start
151                prev_end = next_start = temp_end = 0
152            else:
153                current_speech["end"] = window_size_samples * i
154                speeches.append(current_speech)
155                current_speech = {}
156                prev_end = next_start = temp_end = 0
157                triggered = False
158                continue
159
160        if (speech_prob < neg_threshold) and triggered:
161            if not temp_end:
162                temp_end = window_size_samples * i
163            # condition to avoid cutting in very short silence
164            if (window_size_samples * i) - temp_end > min_silence_samples_at_max_speech:
165                prev_end = temp_end
166            if (window_size_samples * i) - temp_end < min_silence_samples:
167                continue
168            else:
169                current_speech["end"] = temp_end
170                if (
171                    current_speech["end"] - current_speech["start"]
172                ) > min_speech_samples:
173                    speeches.append(current_speech)
174                current_speech = {}
175                prev_end = next_start = temp_end = 0
176                triggered = False
177                continue
178
179    if (
180        current_speech
181        and (audio_length_samples - current_speech["start"]) > min_speech_samples
182    ):
183        current_speech["end"] = audio_length_samples
184        speeches.append(current_speech)
185
186    for i, speech in enumerate(speeches):
187        if i == 0:
188            speech["start"] = int(max(0, speech["start"] - speech_pad_samples))
189        if i != len(speeches) - 1:
190            silence_duration = speeches[i + 1]["start"] - speech["end"]
191            if silence_duration < 2 * speech_pad_samples:
192                speech["end"] += int(silence_duration // 2)
193                speeches[i + 1]["start"] = int(
194                    max(0, speeches[i + 1]["start"] - silence_duration // 2)
195                )
196            else:
197                speech["end"] = int(
198                    min(audio_length_samples, speech["end"] + speech_pad_samples)
199                )
200                speeches[i + 1]["start"] = int(
201                    max(0, speeches[i + 1]["start"] - speech_pad_samples)
202                )
203        else:
204            speech["end"] = int(
205                min(audio_length_samples, speech["end"] + speech_pad_samples)
206            )
207
208    return speeches
209
210
211def collect_chunks(audio: np.ndarray, chunks: List[dict]) -> np.ndarray:
212    """Collects and concatenates audio chunks."""
213    if not chunks:
214        return np.array([], dtype=np.float32)
215
216    return np.concatenate([audio[chunk["start"] : chunk["end"]] for chunk in chunks])
217
218
219class SpeechTimestampsMap:
220    """Helper class to restore original speech timestamps."""
221
222    def __init__(self, chunks: List[dict], sampling_rate: int, time_precision: int = 2):
223        self.sampling_rate = sampling_rate
224        self.time_precision = time_precision
225        self.chunk_end_sample = []
226        self.total_silence_before = []
227
228        previous_end = 0
229        silent_samples = 0
230
231        for chunk in chunks:
232            silent_samples += chunk["start"] - previous_end
233            previous_end = chunk["end"]
234
235            self.chunk_end_sample.append(chunk["end"] - silent_samples)
236            self.total_silence_before.append(silent_samples / sampling_rate)
237
238    def get_original_time(
239        self,
240        time: float,
241        chunk_index: Optional[int] = None,
242    ) -> float:
243        if chunk_index is None:
244            chunk_index = self.get_chunk_index(time)
245
246        total_silence_before = self.total_silence_before[chunk_index]
247        return round(total_silence_before + time, self.time_precision)
248
249    def get_chunk_index(self, time: float) -> int:
250        sample = int(time * self.sampling_rate)
251        return min(
252            bisect.bisect(self.chunk_end_sample, sample),
253            len(self.chunk_end_sample) - 1,
254        )
255
256
257@functools.lru_cache
258def get_vad_model():
259    """Returns the VAD model instance."""
260    abspath = os.path.abspath(__file__)
261    my_dir = os.path.dirname(abspath)
262
263    path = os.path.join(my_dir, "Models/silero_vad.onnx")
264    return SileroVADModel(path)
265
266
267class SileroVADModel:
268    def __init__(self, path):
269        try:
270            import onnxruntime
271        except ImportError as e:
272            raise RuntimeError(
273                "Applying the VAD filter requires the onnxruntime package"
274            ) from e
275
276        opts = onnxruntime.SessionOptions()
277        opts.inter_op_num_threads = 1
278        opts.intra_op_num_threads = 1
279        opts.log_severity_level = 4
280
281        self.session = onnxruntime.InferenceSession(
282            path,
283            providers=["CPUExecutionProvider"],
284            sess_options=opts,
285        )
286
287    def get_initial_state(self, batch_size: int):
288        h = np.zeros((2, batch_size, 64), dtype=np.float32)
289        c = np.zeros((2, batch_size, 64), dtype=np.float32)
290        return h, c
291
292    def __call__(self, x, state, sr: int):
293        if len(x.shape) == 1:
294            x = np.expand_dims(x, 0)
295        if len(x.shape) > 2:
296            raise ValueError(
297                f"Too many dimensions for input audio chunk {len(x.shape)}"
298            )
299        if sr / x.shape[1] > 31.25:
300            raise ValueError("Input audio chunk is too short")
301
302        h, c = state
303
304        ort_inputs = {
305            "input": x,
306            "h": h,
307            "c": c,
308            "sr": np.array(sr, dtype="int64"),
309        }
310
311        out, h, c = self.session.run(None, ort_inputs)
312        state = (h, c)
313
314        return out, state