File size: 10,575 Bytes
f1ef7e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
"""
Audio-only feature extraction for sentiment inference.

This module extracts interpretable call-center audio features from waveform audio.
These features are used by the sentiment pipeline together with emotion
probabilities from the Wav2Vec2 model.

Extracted features:
    - Vocal intensity / loudness
    - Pitch level
    - Pitch variability
    - Speech rate approximation
    - Pause frequency
    - Total silence duration
    - Long silence detection
"""

from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import librosa
import numpy as np

from src.data.audio_dataset import DEFAULT_SAMPLE_RATE, load_audio_file, resolve_audio_path
from src.sentiment_config import IntensityLevel
from src.sentiment_schema import AudioFeatureSummary


@dataclass(frozen=True)
class InferenceAudioFeatureConfig:
    """
    Configuration for audio feature extraction during inference.
    """

    sample_rate: int = DEFAULT_SAMPLE_RATE
    frame_length: int = 1024
    hop_length: int = 512
    max_duration_seconds: Optional[float] = 30.0

    # Silence detection threshold in decibels.
    # Higher means stricter silence detection.
    silence_top_db: int = 30

    # Pause settings.
    min_pause_duration_seconds: float = 0.30
    long_silence_threshold_seconds: float = 1.50


@dataclass(frozen=True)
class RawAudioFeatureValues:
    """
    Raw numeric audio feature values before categorical mapping.
    """

    duration_seconds: float
    rms_mean: float
    rms_std: float
    pitch_mean: float
    pitch_std: float
    voiced_ratio: float
    speech_rate_proxy: float
    pause_count: int
    pause_frequency_per_minute: float
    total_silence_duration_seconds: float
    longest_silence_seconds: float
    long_silence_detected: bool


def _level_from_value(
    value: float,
    low_threshold: float,
    high_threshold: float,
) -> IntensityLevel:
    """
    Convert a numeric value into Low / Medium / High.
    """
    if value < low_threshold:
        return IntensityLevel.LOW

    if value >= high_threshold:
        return IntensityLevel.HIGH

    return IntensityLevel.MEDIUM


def _calculate_rms_features(
    waveform: np.ndarray,
    config: InferenceAudioFeatureConfig,
) -> tuple[float, float]:
    """
    Calculate RMS loudness statistics.
    """
    rms = librosa.feature.rms(
        y=waveform,
        frame_length=config.frame_length,
        hop_length=config.hop_length,
    ).flatten()

    if rms.size == 0:
        return 0.0, 0.0

    return float(np.mean(rms)), float(np.std(rms))


def _calculate_pitch_features(
    waveform: np.ndarray,
    config: InferenceAudioFeatureConfig,
) -> tuple[float, float, float]:
    """
    Calculate pitch mean, pitch standard deviation, and voiced ratio.

    Uses librosa.pyin to estimate fundamental frequency.
    """
    try:
        f0, voiced_flag, _ = librosa.pyin(
            waveform,
            fmin=librosa.note_to_hz("C2"),
            fmax=librosa.note_to_hz("C7"),
            sr=config.sample_rate,
            frame_length=config.frame_length,
            hop_length=config.hop_length,
        )

        if f0 is None or voiced_flag is None:
            return 0.0, 0.0, 0.0

        voiced_pitch = f0[voiced_flag]

        if voiced_pitch.size == 0:
            return 0.0, 0.0, float(np.mean(voiced_flag))

        voiced_pitch = np.nan_to_num(voiced_pitch, nan=0.0)

        return (
            float(np.mean(voiced_pitch)),
            float(np.std(voiced_pitch)),
            float(np.mean(voiced_flag)),
        )

    except Exception:
        return 0.0, 0.0, 0.0


def _calculate_silence_features(
    waveform: np.ndarray,
    config: InferenceAudioFeatureConfig,
) -> tuple[int, float, float, float, bool]:
    """
    Calculate pause and silence-related features.

    Returns:
        pause_count
        pause_frequency_per_minute
        total_silence_duration_seconds
        longest_silence_seconds
        long_silence_detected
    """
    duration_seconds = len(waveform) / config.sample_rate

    non_silent_intervals = librosa.effects.split(
        waveform,
        top_db=config.silence_top_db,
        frame_length=config.frame_length,
        hop_length=config.hop_length,
    )

    if len(non_silent_intervals) == 0:
        return (
            1,
            60.0 / max(duration_seconds, 1e-6),
            duration_seconds,
            duration_seconds,
            duration_seconds >= config.long_silence_threshold_seconds,
        )

    silence_durations = []

    # Silence before first speech segment.
    first_start = non_silent_intervals[0][0]
    if first_start > 0:
        silence_durations.append(first_start / config.sample_rate)

    # Silence gaps between non-silent segments.
    for previous_interval, current_interval in zip(
        non_silent_intervals[:-1],
        non_silent_intervals[1:],
    ):
        previous_end = previous_interval[1]
        current_start = current_interval[0]
        gap_duration = max(0.0, (current_start - previous_end) / config.sample_rate)

        if gap_duration > 0:
            silence_durations.append(gap_duration)

    # Silence after last speech segment.
    last_end = non_silent_intervals[-1][1]
    total_samples = len(waveform)
    if last_end < total_samples:
        silence_durations.append((total_samples - last_end) / config.sample_rate)

    meaningful_pauses = [
        duration
        for duration in silence_durations
        if duration >= config.min_pause_duration_seconds
    ]

    pause_count = len(meaningful_pauses)
    total_silence_duration = float(sum(silence_durations))
    longest_silence = float(max(silence_durations)) if silence_durations else 0.0

    pause_frequency_per_minute = (
        pause_count / max(duration_seconds / 60.0, 1e-6)
    )

    long_silence_detected = longest_silence >= config.long_silence_threshold_seconds

    return (
        pause_count,
        float(pause_frequency_per_minute),
        total_silence_duration,
        longest_silence,
        bool(long_silence_detected),
    )


def _calculate_speech_rate_proxy(
    waveform: np.ndarray,
    config: InferenceAudioFeatureConfig,
) -> float:
    """
    Estimate speech activity rate from onset strength.

    This is not a transcript-based words-per-minute value. It is an audio-only
    rhythm/activity proxy, useful for detecting fast or urgent speech patterns.
    """
    duration_seconds = len(waveform) / config.sample_rate

    if duration_seconds <= 0:
        return 0.0

    onset_envelope = librosa.onset.onset_strength(
        y=waveform,
        sr=config.sample_rate,
        hop_length=config.hop_length,
    )

    if onset_envelope.size == 0:
        return 0.0

    onset_threshold = np.mean(onset_envelope) + 0.5 * np.std(onset_envelope)
    active_onsets = int(np.sum(onset_envelope > onset_threshold))

    return float(active_onsets / max(duration_seconds, 1e-6))


def extract_raw_audio_features(
    audio_path: Path,
    config: Optional[InferenceAudioFeatureConfig] = None,
) -> RawAudioFeatureValues:
    """
    Extract raw numeric audio features from one audio file.

    Args:
        audio_path:
            Path to the audio file. Can be relative to ml-services or absolute.
        config:
            Optional feature extraction configuration.

    Returns:
        RawAudioFeatureValues.
    """
    if config is None:
        config = InferenceAudioFeatureConfig()

    resolved_path = resolve_audio_path(str(audio_path))

    waveform, _ = load_audio_file(
        audio_path=resolved_path,
        target_sample_rate=config.sample_rate,
        max_duration_seconds=config.max_duration_seconds,
    )

    duration_seconds = len(waveform) / config.sample_rate

    rms_mean, rms_std = _calculate_rms_features(waveform, config)
    pitch_mean, pitch_std, voiced_ratio = _calculate_pitch_features(waveform, config)
    speech_rate_proxy = _calculate_speech_rate_proxy(waveform, config)

    (
        pause_count,
        pause_frequency_per_minute,
        total_silence_duration_seconds,
        longest_silence_seconds,
        long_silence_detected,
    ) = _calculate_silence_features(waveform, config)

    return RawAudioFeatureValues(
        duration_seconds=float(duration_seconds),
        rms_mean=rms_mean,
        rms_std=rms_std,
        pitch_mean=pitch_mean,
        pitch_std=pitch_std,
        voiced_ratio=voiced_ratio,
        speech_rate_proxy=speech_rate_proxy,
        pause_count=pause_count,
        pause_frequency_per_minute=pause_frequency_per_minute,
        total_silence_duration_seconds=total_silence_duration_seconds,
        longest_silence_seconds=longest_silence_seconds,
        long_silence_detected=long_silence_detected,
    )


def map_raw_features_to_summary(
    raw_features: RawAudioFeatureValues,
) -> AudioFeatureSummary:
    """
    Convert raw numeric audio features into dashboard-friendly levels.

    These thresholds are practical starting points and can be tuned after testing
    on more call-center audio.
    """
    vocal_intensity = _level_from_value(
        raw_features.rms_mean,
        low_threshold=0.015,
        high_threshold=0.055,
    )

    pitch_level = _level_from_value(
        raw_features.pitch_mean,
        low_threshold=140.0,
        high_threshold=230.0,
    )

    pitch_variability = _level_from_value(
        raw_features.pitch_std,
        low_threshold=25.0,
        high_threshold=65.0,
    )

    speech_rate = _level_from_value(
        raw_features.speech_rate_proxy,
        low_threshold=2.0,
        high_threshold=5.0,
    )

    pause_frequency = _level_from_value(
        raw_features.pause_frequency_per_minute,
        low_threshold=4.0,
        high_threshold=10.0,
    )

    return AudioFeatureSummary(
        vocal_intensity=vocal_intensity,
        pitch_level=pitch_level,
        pitch_variability=pitch_variability,
        speech_rate=speech_rate,
        pause_frequency=pause_frequency,
        long_silence_detected=raw_features.long_silence_detected,
        total_silence_duration_seconds=round(
            raw_features.total_silence_duration_seconds,
            3,
        ),
        overlap_rate=None,
    )


def extract_audio_feature_summary(
    audio_path: Path,
    config: Optional[InferenceAudioFeatureConfig] = None,
) -> AudioFeatureSummary:
    """
    Extract dashboard-ready audio feature summary for one audio file.
    """
    raw_features = extract_raw_audio_features(audio_path=audio_path, config=config)
    return map_raw_features_to_summary(raw_features)