Spaces:
Sleeping
Sleeping
Upload 10 files
Browse filessetting up main engine
- reduction/__init__.py +1 -0
- reduction/__pycache__/__init__.cpython-313.pyc +0 -0
- reduction/__pycache__/compare.cpython-313.pyc +0 -0
- reduction/__pycache__/pipeline.cpython-313.pyc +0 -0
- reduction/__pycache__/stt.cpython-313.pyc +0 -0
- reduction/__pycache__/tts.cpython-313.pyc +0 -0
- reduction/compare.py +157 -0
- reduction/pipeline.py +62 -0
- reduction/stt.py +183 -0
- reduction/tts.py +36 -0
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
|