milicka commited on
Commit
e5bbd16
·
verified ·
1 Parent(s): 2652037

Upload 10 files

Browse files

setting up main engine

reduction/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Phonetic-reduction estimator (STT->TTS comparison pipeline)."""
reduction/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (239 Bytes). View file
 
reduction/__pycache__/compare.cpython-313.pyc ADDED
Binary file (8.25 kB). View file
 
reduction/__pycache__/pipeline.cpython-313.pyc ADDED
Binary file (2.83 kB). View file
 
reduction/__pycache__/stt.cpython-313.pyc ADDED
Binary file (8.54 kB). View file
 
reduction/__pycache__/tts.cpython-313.pyc ADDED
Binary file (2.49 kB). View file
 
reduction/compare.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import librosa
4
+ import numpy as np
5
+
6
+ SR_TARGET = 16000 # common processing rate for MFCCs (also silero-vad's native rate)
7
+
8
+
9
+ _VAD_MODEL = None
10
+
11
+
12
+ def _vad_model():
13
+ global _VAD_MODEL
14
+ if _VAD_MODEL is None:
15
+ from silero_vad import load_silero_vad # lazy import — heavy dep
16
+
17
+ _VAD_MODEL = load_silero_vad()
18
+ return _VAD_MODEL
19
+
20
+
21
+ def load_audio(path: str, sr: int = SR_TARGET) -> tuple[np.ndarray, int]:
22
+ y, real_sr = librosa.load(path, sr=sr, mono=True)
23
+ return y.astype(np.float32), real_sr
24
+
25
+
26
+ def slice_word(y: np.ndarray, sr: int, start_s: float, end_s: float) -> np.ndarray:
27
+ s = max(0, int(round(start_s * sr)))
28
+ e = min(len(y), int(round(end_s * sr)))
29
+ return y[s:e]
30
+
31
+
32
+ def trim_silence(y: np.ndarray, sr: int, top_db: float = 30.0) -> np.ndarray:
33
+ """[fallback] Energy-threshold silence trim. Used when VAD returns empty."""
34
+ if len(y) < int(0.02 * sr):
35
+ return y
36
+ yt, _ = librosa.effects.trim(y, top_db=top_db)
37
+ return yt if len(yt) > 0 else y
38
+
39
+
40
+ def trim_silence_vad(
41
+ y: np.ndarray,
42
+ sr: int,
43
+ threshold: float = 0.5,
44
+ min_speech_ms: int = 30,
45
+ min_silence_ms: int = 80,
46
+ ) -> np.ndarray:
47
+ """VAD-based trim. Crops leading/trailing non-speech around a word slice.
48
+
49
+ Robust to ambient noise, breath, and audible-but-non-phonetic sound that the
50
+ energy-threshold trim would keep (e.g., the deliberate pause attached to "a"
51
+ in rec.3 — measured 0.8 s at top_db=30, ~0.05 s with VAD).
52
+
53
+ For slices shorter than ~100 ms VAD is unreliable; we fall back to the energy
54
+ trim (which mostly does nothing on such short slices anyway).
55
+ """
56
+ import torch
57
+
58
+ if len(y) < int(0.1 * sr):
59
+ return trim_silence(y, sr)
60
+
61
+ # silero-vad operates on 8 kHz or 16 kHz only
62
+ if sr not in (8000, 16000):
63
+ y_vad = librosa.resample(y, orig_sr=sr, target_sr=SR_TARGET)
64
+ scale = sr / SR_TARGET
65
+ vad_sr = SR_TARGET
66
+ else:
67
+ y_vad = y
68
+ scale = 1.0
69
+ vad_sr = sr
70
+
71
+ from silero_vad import get_speech_timestamps
72
+
73
+ audio_t = torch.from_numpy(y_vad.astype(np.float32))
74
+ speech = get_speech_timestamps(
75
+ audio_t,
76
+ _vad_model(),
77
+ sampling_rate=vad_sr,
78
+ threshold=threshold,
79
+ min_speech_duration_ms=min_speech_ms,
80
+ min_silence_duration_ms=min_silence_ms,
81
+ )
82
+ if not speech:
83
+ return trim_silence(y, sr) # fallback to energy trim
84
+
85
+ # Concatenate speech segments, dropping any inter-segment silence.
86
+ # Rationale: silero's min_silence_duration_ms=80 ms means any inter-segment gap
87
+ # is ≥80 ms, which is longer than typical stop closures (40–70 ms in fast Czech),
88
+ # so a multi-segment split signals a real pause/hesitation that should be excluded.
89
+ pieces: list[np.ndarray] = []
90
+ for seg in speech:
91
+ s = max(0, int(round(seg["start"] * scale)))
92
+ e = min(len(y), int(round(seg["end"] * scale)))
93
+ if e > s:
94
+ pieces.append(y[s:e])
95
+ if not pieces:
96
+ return trim_silence(y, sr)
97
+ return pieces[0] if len(pieces) == 1 else np.concatenate(pieces)
98
+
99
+
100
+ def _mfcc_cmn(y: np.ndarray, sr: int, n_mfcc: int = 13, hop_ms: float = 10.0) -> np.ndarray:
101
+ hop = max(1, int(sr * hop_ms / 1000.0))
102
+ n_fft = 512 if sr <= 16000 else 1024
103
+ M = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=n_mfcc, hop_length=hop, n_fft=n_fft)
104
+ # cepstral mean normalization — removes channel / microphone bias per stream
105
+ M = M - M.mean(axis=1, keepdims=True)
106
+ return M
107
+
108
+
109
+ def spectral_distance(
110
+ orig_wav: np.ndarray,
111
+ orig_sr: int,
112
+ tts_wav: np.ndarray,
113
+ tts_sr: int,
114
+ target_sr: int = SR_TARGET,
115
+ ) -> float:
116
+ """DTW-aligned mean MFCC distance between original word and its TTS rendering.
117
+
118
+ Higher = more spectrally distant from canonical TTS = more reduced/different.
119
+ Returns NaN for too-short slices (cannot compute MFCC reliably).
120
+ """
121
+ if orig_sr != target_sr:
122
+ orig_wav = librosa.resample(orig_wav, orig_sr=orig_sr, target_sr=target_sr)
123
+ if tts_sr != target_sr:
124
+ tts_wav = librosa.resample(tts_wav, orig_sr=tts_sr, target_sr=target_sr)
125
+ # need at least ~30 ms of audio for a meaningful MFCC sequence
126
+ if len(orig_wav) < int(0.03 * target_sr) or len(tts_wav) < int(0.03 * target_sr):
127
+ return float("nan")
128
+ M_orig = _mfcc_cmn(orig_wav, target_sr)
129
+ M_tts = _mfcc_cmn(tts_wav, target_sr)
130
+ if M_orig.shape[1] < 2 or M_tts.shape[1] < 2:
131
+ return float("nan")
132
+ D, wp = librosa.sequence.dtw(M_orig, M_tts, metric="euclidean")
133
+ return float(D[-1, -1] / max(1, len(wp)))
134
+
135
+
136
+ def center_scores(rows: list[dict]) -> list[dict]:
137
+ """Add centered scores (mean = 1) to each row.
138
+
139
+ Convention: higher score = less reduced (closer to canonical / longer).
140
+ duration_score = (orig_dur / tts_dur) / mean(orig_dur / tts_dur)
141
+ spectral_score = mean(spec_dist) / spec_dist (inverted, same direction as duration)
142
+ combined_score = arithmetic mean of the two (NaN-safe)
143
+ """
144
+ dur_ratios = np.array([r["duration_ratio"] for r in rows], dtype=float)
145
+ spec_dists = np.array([r["spectral_distance"] for r in rows], dtype=float)
146
+
147
+ mean_dr = np.nanmean(dur_ratios) if np.any(~np.isnan(dur_ratios)) else float("nan")
148
+ mean_sd = np.nanmean(spec_dists) if np.any(~np.isnan(spec_dists)) else float("nan")
149
+
150
+ for r in rows:
151
+ dr = r["duration_ratio"]
152
+ sd = r["spectral_distance"]
153
+ r["duration_score"] = (dr / mean_dr) if (mean_dr and not np.isnan(dr)) else float("nan")
154
+ r["spectral_score"] = (mean_sd / sd) if (sd and not np.isnan(sd) and not np.isnan(mean_sd)) else float("nan")
155
+ components = [v for v in (r["duration_score"], r["spectral_score"]) if not np.isnan(v)]
156
+ r["combined_score"] = float(np.mean(components)) if components else float("nan")
157
+ return rows
reduction/pipeline.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from . import compare, stt, tts
4
+
5
+
6
+ def analyze(
7
+ audio_path: str,
8
+ whisper_model,
9
+ voice,
10
+ language: str = "cs",
11
+ groundtruth: str | None = None,
12
+ trim: bool = True,
13
+ ) -> tuple[list[dict], str, str | None]:
14
+ """Run STT -> per-word TTS -> per-word duration & spectral comparison.
15
+
16
+ If `groundtruth` is provided, its tokens replace Whisper's word identities
17
+ (Whisper still drives timing). This isolates the metric from STT errors.
18
+
19
+ Returns (rows, whisper_transcript, groundtruth_or_None).
20
+ """
21
+ whisper_words, whisper_text = stt.transcribe_words(
22
+ audio_path, whisper_model, language=language
23
+ )
24
+ if groundtruth is not None:
25
+ gt_tokens = stt.tokenize_transcript(groundtruth)
26
+ words = stt.align_to_groundtruth(whisper_words, gt_tokens)
27
+ else:
28
+ words = whisper_words
29
+
30
+ orig_y, orig_sr = compare.load_audio(audio_path)
31
+
32
+ rows: list[dict] = []
33
+ for w in words:
34
+ orig_slice = compare.slice_word(orig_y, orig_sr, w.start, w.end)
35
+ if trim:
36
+ orig_slice = compare.trim_silence_vad(orig_slice, orig_sr)
37
+ orig_dur_used = len(orig_slice) / orig_sr if len(orig_slice) > 0 else 0.0
38
+
39
+ _tts_dur_full, tts_wav, tts_sr = tts.synthesize_duration(voice, w.text)
40
+ if trim:
41
+ tts_wav = compare.trim_silence_vad(tts_wav, tts_sr)
42
+ tts_dur_used = len(tts_wav) / tts_sr if len(tts_wav) > 0 else 0.0
43
+
44
+ spec = compare.spectral_distance(orig_slice, orig_sr, tts_wav, tts_sr)
45
+ dur_ratio = (orig_dur_used / tts_dur_used) if tts_dur_used > 0 else float("nan")
46
+
47
+ rows.append(
48
+ {
49
+ "word": w.text,
50
+ "raw": w.raw.strip(),
51
+ "start": w.start,
52
+ "end": w.end,
53
+ "stt_probability": getattr(w, "probability", float("nan")),
54
+ "orig_duration": orig_dur_used,
55
+ "tts_duration": tts_dur_used,
56
+ "duration_ratio": dur_ratio,
57
+ "spectral_distance": spec,
58
+ }
59
+ )
60
+
61
+ compare.center_scores(rows)
62
+ return rows, whisper_text, groundtruth
reduction/stt.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ from faster_whisper import WhisperModel
8
+
9
+
10
+ _STRIP_RE = re.compile(r"^[\s\.,!?;:\"'„“”‚‘’\(\)\[\]…—–-]+|[\s\.,!?;:\"'„“”‚‘’\(\)\[\]…—–-]+$")
11
+
12
+
13
+ @dataclass
14
+ class Word:
15
+ text: str # normalized form for TTS (lowercased, punctuation stripped)
16
+ raw: str # original Whisper token (kept for debugging / display)
17
+ start: float # seconds in original audio
18
+ end: float
19
+ probability: float = float("nan") # Whisper per-word probability (NaN if unknown)
20
+
21
+ @property
22
+ def duration(self) -> float:
23
+ return self.end - self.start
24
+
25
+
26
+ def _normalize(token: str) -> str:
27
+ return _STRIP_RE.sub("", token).lower()
28
+
29
+
30
+ def transcribe_words(
31
+ audio_path: str,
32
+ model: WhisperModel,
33
+ language: str = "cs",
34
+ ) -> tuple[list[Word], str]:
35
+ """Transcribe `audio_path` and return word-level segmentation + full text.
36
+
37
+ Empty / pure-punctuation tokens are skipped.
38
+ """
39
+ segments, _info = model.transcribe(
40
+ audio_path, language=language, word_timestamps=True
41
+ )
42
+ words: list[Word] = []
43
+ full_text: list[str] = []
44
+ for seg in segments:
45
+ full_text.append(seg.text)
46
+ if not seg.words:
47
+ continue
48
+ for w in seg.words:
49
+ normalized = _normalize(w.word)
50
+ if not normalized:
51
+ continue
52
+ prob = getattr(w, "probability", float("nan"))
53
+ words.append(
54
+ Word(
55
+ text=normalized,
56
+ raw=w.word,
57
+ start=w.start,
58
+ end=w.end,
59
+ probability=float(prob) if prob is not None else float("nan"),
60
+ )
61
+ )
62
+ return words, "".join(full_text).strip()
63
+
64
+
65
+ def load_model(
66
+ size: str = "large-v3",
67
+ device: str = "cpu",
68
+ compute_type: str = "int8",
69
+ download_root: str = "models/whisper",
70
+ ) -> WhisperModel:
71
+ return WhisperModel(
72
+ size, device=device, compute_type=compute_type, download_root=download_root
73
+ )
74
+
75
+
76
+ def tokenize_transcript(text: str) -> list[str]:
77
+ """Split a free-form transcript into normalized word tokens (lowercase, no punct)."""
78
+ return [t for t in (_normalize(tok) for tok in text.split()) if t]
79
+
80
+
81
+ def _char_dist(a: str, b: str) -> float:
82
+ """Normalized character-level edit distance in [0, 1]. 0 = identical."""
83
+ if not a and not b:
84
+ return 0.0
85
+ return 1.0 - difflib.SequenceMatcher(a=a, b=b, autojunk=False).ratio()
86
+
87
+
88
+ def align_to_groundtruth(
89
+ whisper_words: list[Word], gt_tokens: list[str], gap_penalty: float = 0.7
90
+ ) -> list[Word]:
91
+ """Replace Whisper word identities with ground-truth tokens, keeping Whisper timing.
92
+
93
+ Uses Needleman–Wunsch alignment with character-level edit distance as substitution cost.
94
+ Behavior:
95
+ - Aligned (W_i, GT_j): emit Word(text=GT_j, start/end from W_i).
96
+ - GT_j unmatched: try to interpolate timing from neighboring aligned tokens; if
97
+ impossible (start/end of utterance with no anchor), drop with a stderr warning.
98
+ - W_i unmatched: drop (Whisper inserted a phantom token).
99
+ """
100
+ if not whisper_words:
101
+ return []
102
+ if not gt_tokens:
103
+ return list(whisper_words)
104
+
105
+ n, m = len(whisper_words), len(gt_tokens)
106
+ dp = [[0.0] * (m + 1) for _ in range(n + 1)]
107
+ bt = [[""] * (m + 1) for _ in range(n + 1)]
108
+ for i in range(1, n + 1):
109
+ dp[i][0] = i * gap_penalty
110
+ bt[i][0] = "D"
111
+ for j in range(1, m + 1):
112
+ dp[0][j] = j * gap_penalty
113
+ bt[0][j] = "I"
114
+ for i in range(1, n + 1):
115
+ for j in range(1, m + 1):
116
+ sub = dp[i - 1][j - 1] + _char_dist(whisper_words[i - 1].text, gt_tokens[j - 1])
117
+ dele = dp[i - 1][j] + gap_penalty
118
+ ins = dp[i][j - 1] + gap_penalty
119
+ best = min(sub, dele, ins)
120
+ dp[i][j] = best
121
+ bt[i][j] = "M" if best == sub else ("D" if best == dele else "I")
122
+
123
+ # backtrack
124
+ pairs: list[tuple[int | None, int | None]] = []
125
+ i, j = n, m
126
+ while i > 0 or j > 0:
127
+ op = bt[i][j]
128
+ if op == "M":
129
+ pairs.append((i - 1, j - 1))
130
+ i -= 1
131
+ j -= 1
132
+ elif op == "D":
133
+ pairs.append((i - 1, None))
134
+ i -= 1
135
+ else:
136
+ pairs.append((None, j - 1))
137
+ j -= 1
138
+ pairs.reverse()
139
+
140
+ # First pass: emit matched + skip Whisper-only; collect GT-only with neighbor anchors.
141
+ out: list[Word | None] = []
142
+ pending_gt: list[int] = [] # GT indices waiting for next anchor
143
+ last_end: float | None = None
144
+ for w_idx, g_idx in pairs:
145
+ if w_idx is not None and g_idx is not None:
146
+ ww = whisper_words[w_idx]
147
+ # if there are pending GT tokens, distribute them between last_end and ww.start
148
+ if pending_gt:
149
+ if last_end is not None:
150
+ span_start, span_end = last_end, ww.start
151
+ n_pend = len(pending_gt)
152
+ for k, gi in enumerate(pending_gt):
153
+ ts = span_start + (span_end - span_start) * k / n_pend
154
+ te = span_start + (span_end - span_start) * (k + 1) / n_pend
155
+ out.append(Word(text=gt_tokens[gi], raw=gt_tokens[gi], start=ts, end=te))
156
+ else:
157
+ # at start of utterance with no anchor — assign 0..ww.start span
158
+ n_pend = len(pending_gt)
159
+ for k, gi in enumerate(pending_gt):
160
+ ts = ww.start * k / n_pend
161
+ te = ww.start * (k + 1) / n_pend
162
+ out.append(Word(text=gt_tokens[gi], raw=gt_tokens[gi], start=ts, end=te))
163
+ pending_gt = []
164
+ out.append(Word(text=gt_tokens[g_idx], raw=gt_tokens[g_idx], start=ww.start, end=ww.end))
165
+ last_end = ww.end
166
+ elif w_idx is not None and g_idx is None:
167
+ # Whisper-only — skip, but advance time anchor
168
+ last_end = whisper_words[w_idx].end
169
+ else: # GT-only
170
+ pending_gt.append(g_idx) # type: ignore[arg-type]
171
+
172
+ # Trailing pending GT tokens (no right-anchor): assign small constant duration
173
+ if pending_gt:
174
+ if last_end is not None:
175
+ span_start = last_end
176
+ span_end = last_end + 0.3 * len(pending_gt) # rough fallback
177
+ n_pend = len(pending_gt)
178
+ for k, gi in enumerate(pending_gt):
179
+ ts = span_start + (span_end - span_start) * k / n_pend
180
+ te = span_start + (span_end - span_start) * (k + 1) / n_pend
181
+ out.append(Word(text=gt_tokens[gi], raw=gt_tokens[gi], start=ts, end=te))
182
+
183
+ return [w for w in out if w is not None]
reduction/tts.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import wave
5
+
6
+ import numpy as np
7
+ from piper import PiperVoice
8
+
9
+
10
+ def load_voice(voice_path: str = "models/piper/cs_CZ-jirka-medium.onnx") -> PiperVoice:
11
+ return PiperVoice.load(voice_path)
12
+
13
+
14
+ def synthesize(voice: PiperVoice, text: str) -> tuple[np.ndarray, int]:
15
+ """Synthesize `text` and return (mono float32 waveform in [-1,1], sample_rate)."""
16
+ buf = io.BytesIO()
17
+ with wave.open(buf, "wb") as wf:
18
+ voice.synthesize_wav(text, wf)
19
+ buf.seek(0)
20
+ with wave.open(buf, "rb") as wf:
21
+ sr = wf.getframerate()
22
+ n_channels = wf.getnchannels()
23
+ sampwidth = wf.getsampwidth()
24
+ raw = wf.readframes(wf.getnframes())
25
+ if sampwidth != 2:
26
+ raise RuntimeError(f"Unexpected Piper sample width: {sampwidth} bytes")
27
+ pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
28
+ if n_channels > 1:
29
+ pcm = pcm.reshape(-1, n_channels).mean(axis=1)
30
+ return pcm, sr
31
+
32
+
33
+ def synthesize_duration(voice: PiperVoice, text: str) -> tuple[float, np.ndarray, int]:
34
+ """Convenience: returns (duration_seconds, waveform, sample_rate)."""
35
+ wav, sr = synthesize(voice, text)
36
+ return len(wav) / sr, wav, sr