File size: 12,002 Bytes
1a0e6e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""Audio Loader Module.

Loads and preprocesses WAV audio files for the similarity pipeline.

Notes:
- This pipeline uses a strict single-backend mechanism (torchaudio) for reading audio. 
  This methodological decision ensures strict experimental reproducibility by 
  avoiding backend-dependent variations in decoding or numerical representation.
- Optional WebRTC VAD-based endpoint trimming removes leading/trailing silence
  without cutting internal pauses.
"""

from __future__ import annotations

import struct
from typing import Tuple

import numpy as np
import torch
import torchaudio

try:
    import soundfile as sf  # type: ignore

    _SOUNDFILE_AVAILABLE = True
except ImportError:
    _SOUNDFILE_AVAILABLE = False

try:
    import webrtcvad  # type: ignore

    _WEBRTCVAD_AVAILABLE = True
except ImportError:
    _WEBRTCVAD_AVAILABLE = False


class AudioLoader:
    """Loads and preprocesses audio files."""

    def __init__(
        self,
        target_sr: int = 16000,
        use_vad: bool = True,
        vad_mode: int = 1,
        vad_frame_ms: int = 10,
        vad_onset_frames: int = 2,
        vad_offset_frames: int = 4,
        energy_trim_threshold: float = 0.06,
    ):
        """
        Initialize AudioLoader.

        Args:
            target_sr: Target sampling rate (default: 16000 Hz for WavLM).
            use_vad: If True, apply WebRTC VAD endpoint trimming to strip
                leading/trailing silence.
            vad_mode: WebRTC VAD aggressiveness (0–3). 1–2 recommended.
            vad_frame_ms: Frame length in ms for VAD (10, 20, or 30).
            vad_onset_frames: Minimum consecutive voiced frames to detect
                speech onset (hysteresis).
            vad_offset_frames: Minimum consecutive unvoiced frames to detect
                speech offset (hysteresis).
            energy_trim_threshold: Fraction of peak absolute amplitude used
                as threshold for energy-based refinement after VAD (0.0–1.0).
                Frames with amplitude below this fraction of the local peak
                are trimmed from the head and tail.
        """
        self.target_sr = target_sr
        self.use_vad = use_vad
        self.vad_mode = vad_mode
        self.vad_frame_ms = vad_frame_ms
        self.vad_onset_frames = vad_onset_frames
        self.vad_offset_frames = vad_offset_frames
        self.energy_trim_threshold = energy_trim_threshold
    
    @staticmethod
    def _load_with_soundfile(audio_path: str) -> Tuple[torch.Tensor, int]:
        """Fallback loader using soundfile (libsndfile).
        
        Used when torchaudio's default backend (torchcodec) is unavailable,
        e.g. on systems without FFmpeg DLLs.  Produces identical float32
        waveform tensors for WAV files.
        """
        if not _SOUNDFILE_AVAILABLE:
            raise ImportError(
                "soundfile is not installed. Install it with: pip install soundfile"
            )
        data, sample_rate = sf.read(audio_path, dtype="float32", always_2d=True)
        # soundfile returns (num_samples, channels) → transpose to (channels, num_samples)
        waveform = torch.from_numpy(data.T)
        return waveform, sample_rate

    def load_audio(self, audio_path: str) -> Tuple[torch.Tensor, int]:
        """
        Load audio file and resample if necessary.
        
        Uses soundfile (libsndfile) as the primary loader because
        torchaudio ≥2.11 requires torchcodec + FFmpeg DLLs which are
        often unavailable on Windows.  soundfile handles WAV natively
        and produces identical float32 results.
        
        Falls back to torchaudio.load() if soundfile is unavailable.
        
        Args:
            audio_path: Path to WAV audio file
            
        Returns:
            Tuple of (waveform tensor, sample_rate)
            waveform shape: (1, num_samples) for mono
            
        Raises:
            RuntimeError: If both soundfile and torchaudio fail to read the audio file.
        """
        # Primary: soundfile (no FFmpeg dependency, handles WAV natively)
        try:
            waveform, sample_rate = self._load_with_soundfile(audio_path)
        except Exception as sf_err:
            # Fallback: torchaudio (needs torchcodec/FFmpeg on ≥2.11)
            try:
                waveform, sample_rate = torchaudio.load(audio_path)
            except Exception as torchaudio_err:
                raise RuntimeError(
                    f"Failed to read audio file '{audio_path}'. "
                    f"soundfile error: {sf_err} | "
                    f"torchaudio error: {torchaudio_err}"
                )
        
        # Convert to mono if stereo
        if waveform.shape[0] > 1:
            waveform = torch.mean(waveform, dim=0, keepdim=True)
        
        # Resample if necessary
        if sample_rate != self.target_sr:
            resampler = torchaudio.transforms.Resample(
                orig_freq=sample_rate, 
                new_freq=self.target_sr
            )
            waveform = resampler(waveform)
            sample_rate = self.target_sr
        
        return waveform, sample_rate
    
    # ------------------------------------------------------------------
    # VAD-based endpoint trimming
    # ------------------------------------------------------------------

    def vad_trim_endpoints(
        self, waveform: torch.Tensor, sample_rate: int
    ) -> torch.Tensor:
        """Trim leading and trailing silence using WebRTC VAD.

        Only the outermost silent segments are removed; internal pauses
        (e.g. between words or ayat) are preserved intact.

        The algorithm uses a hysteresis mechanism:
        * **Onset**: the first sample of the first window in a run of at
          least ``vad_onset_frames`` consecutive *voiced* frames is taken
          as the speech start.
        * **Offset**: the last sample of the last *voiced* frame before a
          run of at least ``vad_offset_frames`` consecutive *unvoiced*
          frames that extends to the end of the signal is taken as the
          speech end.

        Args:
            waveform: Mono waveform tensor of shape ``(1, num_samples)``
                in float32 (values in roughly [-1, 1]).
            sample_rate: Must be 8000, 16000, 32000, or 48000.

        Returns:
            Trimmed waveform tensor ``(1, trimmed_samples)``, float32.
        """
        if not _WEBRTCVAD_AVAILABLE:
            raise ImportError(
                "webrtcvad is not installed but VAD trimming was requested. "
                "Please install it with: pip install webrtcvad"
            )

        # --- prepare int16 PCM bytes for webrtcvad -----------------------
        audio_np: np.ndarray = waveform.squeeze(0).numpy()  # (num_samples,)
        # Clip and convert float32 -> int16
        pcm_int16 = np.clip(audio_np * 32767, -32768, 32767).astype(np.int16)

        frame_len = int(sample_rate * self.vad_frame_ms / 1000)  # samples per frame
        num_frames = len(pcm_int16) // frame_len

        if num_frames == 0:
            return waveform  # too short to analyse

        vad = webrtcvad.Vad(self.vad_mode)

        # --- per-frame voiced/unvoiced labels ----------------------------
        is_voiced: list[bool] = []
        for i in range(num_frames):
            start = i * frame_len
            end = start + frame_len
            frame_bytes = struct.pack(f"<{frame_len}h", *pcm_int16[start:end])
            is_voiced.append(vad.is_speech(frame_bytes, sample_rate))

        # --- onset detection (left-to-right) -----------------------------
        speech_start_frame: int = 0
        consecutive_voiced = 0
        for idx, voiced in enumerate(is_voiced):
            if voiced:
                consecutive_voiced += 1
                if consecutive_voiced >= self.vad_onset_frames:
                    speech_start_frame = idx - self.vad_onset_frames + 1
                    break
            else:
                consecutive_voiced = 0
        else:
            # No onset found – return original waveform (all silence?)
            return waveform

        # --- offset detection (right-to-left) ----------------------------
        trailing_unvoiced = 0
        for idx in range(num_frames - 1, -1, -1):
            if not is_voiced[idx]:
                trailing_unvoiced += 1
            else:
                break

        if trailing_unvoiced >= self.vad_offset_frames:
            speech_end_frame = num_frames - trailing_unvoiced - 1
        else:
            speech_end_frame = num_frames - 1  # no significant trailing silence

        # Sanity: ensure start <= end
        if speech_start_frame > speech_end_frame:
            return waveform

        # --- reconstruct trimmed waveform --------------------------------
        start_sample = speech_start_frame * frame_len
        end_sample = (speech_end_frame + 1) * frame_len
        end_sample = min(end_sample, waveform.shape[1])

        trimmed = waveform[:, start_sample:end_sample]

        # Guard against empty result
        if trimmed.shape[1] == 0:
            return waveform

        # --- energy-based refinement -------------------------------------
        trimmed = self._energy_refine(trimmed)

        return trimmed

    def _energy_refine(self, waveform: torch.Tensor) -> torch.Tensor:
        """Refine VAD boundaries by trimming low-energy head/tail.

        Uses a short-window RMS envelope to find the first and last
        sample whose energy exceeds ``energy_trim_threshold`` of the
        peak RMS value.  This removes residual breath or resonance
        that WebRTC VAD may leave behind.

        Args:
            waveform: Mono waveform ``(1, num_samples)``.

        Returns:
            Refined waveform ``(1, refined_samples)``.
        """
        if self.energy_trim_threshold <= 0.0:
            return waveform

        audio = waveform.squeeze(0).numpy()  # (num_samples,)
        abs_env = np.abs(audio)

        # Short-window RMS (window ≈ 10 ms)
        win = max(int(self.target_sr * 0.01), 1)
        # Cumulative sum trick for fast moving average of squared signal
        sq = audio.astype(np.float64) ** 2
        cs = np.concatenate(([0.0], np.cumsum(sq)))
        rms = np.sqrt((cs[win:] - cs[:-win]) / win).astype(np.float32)

        if len(rms) == 0:
            return waveform

        peak_rms = rms.max()
        if peak_rms == 0:
            return waveform

        threshold = self.energy_trim_threshold * peak_rms

        # Find first and last frame above threshold
        above = np.where(rms >= threshold)[0]
        if len(above) == 0:
            return waveform

        start = int(above[0])
        end = int(above[-1]) + win  # include the window trailing edge
        end = min(end, len(audio))

        refined = waveform[:, start:end]
        if refined.shape[1] == 0:
            return waveform

        return refined

    def normalize_audio(self, waveform: torch.Tensor) -> torch.Tensor:
        """
        Normalize audio waveform to [-1, 1] range.
        
        Args:
            waveform: Audio waveform tensor
            
        Returns:
            Normalized waveform
        """
        # Normalize to [-1, 1]
        max_val = torch.max(torch.abs(waveform))
        if max_val > 0:
            waveform = waveform / max_val
        return waveform
    
    def load_and_preprocess(self, audio_path: str) -> torch.Tensor:
        """
        Load and preprocess audio in one step.
        
        Args:
            audio_path: Path to WAV audio file
            
        Returns:
            Preprocessed waveform tensor (1, num_samples)
        """
        waveform, sr = self.load_audio(audio_path)

        # VAD endpoint trimming (after resampling, before normalisation)
        if self.use_vad:
            waveform = self.vad_trim_endpoints(waveform, sr)

        waveform = self.normalize_audio(waveform)
        return waveform