MLSpeech commited on
Commit
0cf1a58
·
verified ·
1 Parent(s): 2b262e8

Deploy FALCON demo (app + bundled MFA G2P assets + example inputs)

Browse files
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.fst filter=lfs diff=lfs merge=lfs -text
2
+ *.wav filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,13 +1,31 @@
1
  ---
2
- title: FALCON
3
- emoji: 📊
4
- colorFrom: green
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FALCON Forced Aligner
3
+ emoji: 🦅
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.1
8
+ python_version: "3.8"
9
  app_file: app.py
10
  pinned: false
11
+ license: mit
12
+ short_description: Neural forced alignment via Soft Dynamic Programming
13
  ---
14
 
15
+ # FALCON Forced Alignment through Contrastive Optimization Networks
16
+
17
+ Interactive demo of **FALCON**, a fully differentiable neural forced aligner that predicts
18
+ precise **phoneme- and word-level** boundary timestamps from a waveform + transcript, using a
19
+ Soft Dynamic Programming decoder.
20
+
21
+ Upload audio + a transcript (`.phn` / `.wrd` / `.txt`), choose the options, and get a boundary
22
+ table, a downloadable Praat `.TextGrid`, and a time-aligned visualization (waveform ·
23
+ spectrogram · phoneme posteriors · Soft-DP path · contrastive score).
24
+
25
+ - **Paper:** *Fully Differentiable Neural Forced Alignment via Soft Dynamic Programming* — [arXiv:2606.25460](https://arxiv.org/abs/2606.25460)
26
+ - **Code:** https://github.com/MLSpeech/FALCON
27
+ - **Weights:** https://huggingface.co/MLSpeech/FALCON-weights
28
+
29
+ Example inputs are in `assets/` — the TIMIT sentence *"Don't ask me to carry an oily rag like that."*
30
+ in every supported format. The checkpoints are downloaded automatically from the weights repo on
31
+ first use (this runs on a free CPU Space, so the first alignment takes a moment to fetch a model).
__pycache__/app.cpython-38.pyc ADDED
Binary file (14.8 kB). View file
 
__pycache__/dataloader.cpython-38.pyc ADDED
Binary file (5.54 kB). View file
 
__pycache__/dutch_preprocess.cpython-38.pyc ADDED
Binary file (4.62 kB). View file
 
__pycache__/mfa_g2p.cpython-38.pyc ADDED
Binary file (7.01 kB). View file
 
__pycache__/next_frame_classifier.cpython-38.pyc ADDED
Binary file (13.8 kB). View file
 
__pycache__/predict.cpython-38.pyc ADDED
Binary file (9.13 kB). View file
 
__pycache__/utils.cpython-38.pyc ADDED
Binary file (22.9 kB). View file
 
__pycache__/word_g2p.cpython-38.pyc ADDED
Binary file (4.29 kB). View file
 
app.py ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FALCON web demo — interactive forced alignment in the browser.
3
+
4
+ Two pretrained checkpoints are used by FALCON (under pretrained_models/):
5
+ - falcon_timit_english.pt — TIMIT-trained, best for English phoneme alignment
6
+ - falcon_joint_multilingual.pt — joint TIMIT+Buckeye model; best for cross-lingual /
7
+ multilingual zero-shot alignment (Dutch, German,
8
+ Hebrew, ...) at both phoneme and word level.
9
+
10
+ The app picks one automatically from the `Language` radio (english → TIMIT,
11
+ multilingual → joint); a custom .pt upload overrides both.
12
+
13
+ For HuggingFace Spaces deployment, set Space Secrets:
14
+ HF_MODEL_REPO — e.g. "MLSpeech/FALCON-weights"
15
+ HF_TOKEN — only needed for private repos
16
+ The app will download `falcon_timit_english.pt` and `falcon_joint_multilingual.pt`
17
+ from that repo on first use.
18
+ """
19
+ import os
20
+ import re
21
+ import shutil
22
+ import sys
23
+ import tempfile
24
+ import threading
25
+ import time
26
+
27
+ import gradio as gr
28
+ import textgrid
29
+ import torchaudio
30
+
31
+ import utils
32
+ from predict import main_predict
33
+
34
+ # On HF Spaces, point the "MFA-like" word G2P at the bundled dictionaries / G2P FST
35
+ # and at this interpreter (which has pynini), so it works without a separate MFA
36
+ # aligner conda env. No effect off Spaces — your local MFA install is used as-is.
37
+ if os.environ.get("SPACE_ID"):
38
+ _SPACE_DIR = os.path.dirname(os.path.abspath(__file__))
39
+ os.environ.setdefault("MFA_ROOT_DIR", os.path.join(_SPACE_DIR, "mfa_assets"))
40
+ os.environ.setdefault("FDNFA_MFA_ENV_PY", sys.executable)
41
+
42
+ # ── Checkpoint configuration ──────────────────────────────────────────────────
43
+
44
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
45
+ PRETRAINED_DIR = os.path.join(SCRIPT_DIR, "pretrained_models")
46
+
47
+ CKPT_FILES = {
48
+ "english": "falcon_timit_english.pt",
49
+ "buckeye": "falcon_buckeye_english.pt",
50
+ "multilingual": "falcon_joint_multilingual.pt",
51
+ }
52
+ CKPT_LABELS = {
53
+ "english": "Read English (recommended) — TIMIT model",
54
+ "buckeye": "Spontaneous English (recommended) — Buckeye model",
55
+ "multilingual": "Multilingual — joint TIMIT+Buckeye model",
56
+ }
57
+
58
+ _ckpt_cache = {}
59
+
60
+ def _resolve_ckpt(key: str):
61
+ """Resolve checkpoint path: cache → local file → HF Hub. Returns None if all fail."""
62
+ if key in _ckpt_cache and os.path.exists(_ckpt_cache[key]):
63
+ return _ckpt_cache[key]
64
+
65
+ filename = CKPT_FILES[key]
66
+ local_path = os.path.join(PRETRAINED_DIR, filename)
67
+ if os.path.exists(local_path):
68
+ _ckpt_cache[key] = local_path
69
+ return local_path
70
+
71
+ repo = os.environ.get("HF_MODEL_REPO", "")
72
+ if repo:
73
+ try:
74
+ from huggingface_hub import hf_hub_download
75
+ path = hf_hub_download(
76
+ repo_id=repo,
77
+ filename=filename,
78
+ token=os.environ.get("HF_TOKEN"),
79
+ )
80
+ _ckpt_cache[key] = path
81
+ return path
82
+ except Exception as exc:
83
+ print(f"[FALCON] HF Hub fetch failed for {filename}: {exc}")
84
+
85
+ return None
86
+
87
+ _inference_lock = threading.Lock()
88
+
89
+ # ── Heartbeat-based auto-shutdown (local runs only) ───────────────────────────
90
+ # The open browser tab pings _heartbeat() every few seconds. A watchdog thread
91
+ # exits the process when the pings stop (tab closed / browser crashed / unload
92
+ # event dropped). _last_ping stays None until the first browser connects, so the
93
+ # server never self-exits before anyone opens it.
94
+ _last_ping = [None]
95
+ _HEARTBEAT_TIMEOUT = 90 # secs of silence before the local server self-exits
96
+
97
+ def _heartbeat():
98
+ _last_ping[0] = time.time()
99
+
100
+ def _start_shutdown_watchdog():
101
+ def _watch():
102
+ while True:
103
+ time.sleep(5)
104
+ last = _last_ping[0]
105
+ if last is not None and (time.time() - last) > _HEARTBEAT_TIMEOUT:
106
+ os._exit(0)
107
+ threading.Thread(target=_watch, daemon=True).start()
108
+
109
+ # ── Internal language routing ────────────────────────────────────────────────
110
+
111
+ def _internal_language(lang: str, mode: str, ann_ext: str) -> str:
112
+ """
113
+ Map UI choices to the internal `language` flag understood by main_predict.
114
+
115
+ 'english' = no G2P; assumes labels are already TIMIT-39 phonemes.
116
+ 'dutch' = G2P pipeline (panphon-based articulatory mapping). Used for:
117
+ • any non-English language
118
+ • word-level alignment (.wrd, words need phoneme decomposition)
119
+ • plain text input (could be words or arbitrary phonemes)
120
+
121
+ NOTE: `ann_ext` here must be the *original* extension supplied by the user —
122
+ not the post-rewrite extension after a .txt → dummy .phn synthesis.
123
+ """
124
+ if lang == "english" and mode == "phoneme" and ann_ext.lower() == "phn":
125
+ return "english"
126
+ return "dutch"
127
+
128
+ # ── Word-level G2P selection ──────────────────────────────────────────────────
129
+
130
+ # Optional input-language hint -> (espeak voice, MFA voice or None). MFA ships
131
+ # pronunciation models only for en/de/nl; everything else uses espeak, or "none"
132
+ # (romanized characters -> LH39) when even espeak has no voice.
133
+ G2P_LANG_CHOICES = [
134
+ "English (default)", "German", "Dutch", "Hebrew", "French",
135
+ "Spanish", "Italian", "Russian", "Portuguese", "Other / unknown",
136
+ ]
137
+ _G2P_LANG_MAP = {
138
+ "English (default)": ("en-us", "en-us"),
139
+ "German": ("de", "de"),
140
+ "Dutch": ("nl", "nl"),
141
+ "Hebrew": ("he", None),
142
+ "French": ("fr", None),
143
+ "Spanish": ("es", None),
144
+ "Italian": ("it", None),
145
+ "Russian": ("ru", None),
146
+ "Portuguese": ("pt", None),
147
+ "Other / unknown": ("en-us", None),
148
+ }
149
+
150
+ def _resolve_g2p(g2p_choice, lang_choice):
151
+ """Map the (G2P option, input-language) UI choices to a concrete backend.
152
+
153
+ Returns (backend, voice, note). backend in {"mfa", "espeak", "char"}. Honors
154
+ an explicit espeak / MFA-like / none choice but auto-falls-back when the
155
+ chosen backend has no model for the language; "Auto" picks the best available.
156
+ """
157
+ espeak_voice, mfa_voice = _G2P_LANG_MAP.get(lang_choice, ("en-us", "en-us"))
158
+ try:
159
+ import mfa_g2p
160
+ mfa_ok = mfa_voice is not None and mfa_g2p.mfa_available(mfa_voice)
161
+ except Exception:
162
+ mfa_ok = False
163
+ choice = (g2p_choice or "Auto").lower()
164
+
165
+ if choice.startswith("none"):
166
+ return "char", espeak_voice or "en-us", "none (romanization)"
167
+ if choice.startswith("mfa"):
168
+ if mfa_ok:
169
+ return "mfa", mfa_voice, "MFA-like"
170
+ if espeak_voice:
171
+ return "espeak", espeak_voice, "espeak (no MFA model for this language)"
172
+ return "char", "en-us", "none (no MFA/espeak model)"
173
+ if choice.startswith("espeak"):
174
+ if espeak_voice:
175
+ return "espeak", espeak_voice, "espeak"
176
+ if mfa_ok:
177
+ return "mfa", mfa_voice, "MFA-like (no espeak voice for this language)"
178
+ return "char", "en-us", "none"
179
+ # Auto (recommended)
180
+ if mfa_ok:
181
+ return "mfa", mfa_voice, "MFA-like (auto)"
182
+ if espeak_voice:
183
+ return "espeak", espeak_voice, "espeak (auto)"
184
+ return "char", "en-us", "none (auto)"
185
+
186
+ # ── Core handler ──────────────────────────────────────────────────────────────
187
+
188
+ OUTPUTS_NONE = (None, None, None, None, None) # 5 None for the non-status outputs
189
+
190
+ def run_alignment(audio_file, annotation_file, ckpt_upload, mode, lang,
191
+ pretrained_choice, w_phi, g2p_choice="Auto (recommended)",
192
+ lang_choice="English (default)",
193
+ progress=gr.Progress(track_tqdm=True)):
194
+ if not audio_file or not annotation_file:
195
+ return ("Please upload both an audio file and an annotation file.", *OUTPUTS_NONE)
196
+
197
+ ckpt_to_use = ckpt_upload if ckpt_upload else _resolve_ckpt(pretrained_choice)
198
+ if not ckpt_to_use or not os.path.exists(ckpt_to_use):
199
+ return (
200
+ f"No checkpoint found. Expected {CKPT_FILES[pretrained_choice]} "
201
+ f"in {PRETRAINED_DIR}, or HF_MODEL_REPO set, or upload a .pt file.",
202
+ *OUTPUTS_NONE,
203
+ )
204
+
205
+ progress(0.1, desc="Preparing workspace...")
206
+ workspace = tempfile.mkdtemp(prefix="falcon_")
207
+ base = "input"
208
+ wav_path = os.path.join(workspace, f"{base}.wav")
209
+
210
+ original_ext = os.path.basename(annotation_file).split(".")[-1].lower()
211
+ ann_ext = original_ext
212
+ ann_path = os.path.join(workspace, f"{base}.{ann_ext}")
213
+
214
+ # Resample audio to 16 kHz mono
215
+ try:
216
+ audio, sr = torchaudio.load(audio_file)
217
+ if audio.shape[0] > 1:
218
+ audio = audio.mean(dim=0, keepdim=True)
219
+ if sr != 16000:
220
+ audio = torchaudio.functional.resample(audio, sr, 16000)
221
+ torchaudio.save(wav_path, audio, 16000)
222
+ except Exception as exc:
223
+ return (f"Audio error: {exc}", *OUTPUTS_NONE)
224
+
225
+ shutil.copy(annotation_file, ann_path)
226
+
227
+ # Capture the original input tokens (whatever the user supplied per line):
228
+ # .phn → phoneme labels
229
+ # .wrd → word labels
230
+ # .txt → space-separated tokens (words or phonemes)
231
+ if original_ext == "txt":
232
+ with open(ann_path) as f:
233
+ orig_tokens = re.sub(r"[^\w\s]", "", f.read().strip()).split()
234
+ # TIMIT .txt files are "<start_sample> <end_sample> <sentence>" — drop the
235
+ # leading sample indices so they aren't mistaken for words.
236
+ if len(orig_tokens) >= 3 and orig_tokens[0].isdigit() and orig_tokens[1].isdigit():
237
+ orig_tokens = orig_tokens[2:]
238
+ if not orig_tokens:
239
+ return ("Text annotation is empty after stripping punctuation.", *OUTPUTS_NONE)
240
+ # Synthesize a uniform-segments dummy .phn so downstream code has timestamps.
241
+ audio_len = audio.shape[1]
242
+ interval = audio_len / len(orig_tokens)
243
+ ann_ext = "phn"
244
+ ann_path = os.path.join(workspace, f"{base}.{ann_ext}")
245
+ with open(ann_path, "w") as f:
246
+ for i, tok in enumerate(orig_tokens):
247
+ f.write(f"{int(i * interval)} {int((i + 1) * interval)} {tok}\n")
248
+ else:
249
+ with open(ann_path) as f:
250
+ orig_tokens = [ln.strip().split()[-1] for ln in f if ln.strip()]
251
+
252
+ # Route by ORIGINAL extension (post-rewrite ann_ext is "phn" for txt inputs).
253
+ language = _internal_language(lang, mode, original_ext)
254
+
255
+ # Word-level G2P: convert orthographic words -> LH39 phonemes with the chosen
256
+ # front-end (espeak, or the MFA english_us_arpa G2P used in the paper), then
257
+ # align via the stock phoneme path. Replaces the legacy letter-by-letter
258
+ # mapping. Only applies to real word input (.wrd / .txt / .word).
259
+ # Word/text inputs (.wrd / .txt) always go through the proper word -> LH39 G2P,
260
+ # regardless of the phoneme/word toggle — otherwise phoneme mode would fall back
261
+ # to a crude character mapping and misalign. (.phn is the phoneme-input path.)
262
+ app_mapped_ph = None
263
+ word_g2p_note = ""
264
+ if original_ext in ("wrd", "txt", "word") and orig_tokens:
265
+ import word_g2p
266
+ backend, voice, g2p_note = _resolve_g2p(g2p_choice, lang_choice)
267
+ try:
268
+ app_mapped_ph = [word_g2p.word_to_lh39(tok, voice=voice, backend=backend)
269
+ for tok in orig_tokens]
270
+ except Exception as g2p_exc:
271
+ # A missing model must never break the whole run — fall back.
272
+ print(f"[FALCON] G2P backend '{backend}' failed ({g2p_exc}); falling back.")
273
+ try:
274
+ app_mapped_ph = [word_g2p.word_to_lh39(tok, voice=voice or "en-us",
275
+ backend="espeak")
276
+ for tok in orig_tokens]
277
+ g2p_note += " → espeak fallback"
278
+ except Exception:
279
+ app_mapped_ph = [word_g2p.word_to_lh39(tok, backend="char")
280
+ for tok in orig_tokens]
281
+ g2p_note += " → none fallback"
282
+ word_g2p_note = f" Word G2P: {g2p_note}."
283
+ phons = [ph for seq in app_mapped_ph for ph in seq] or ["sil"]
284
+ # Rewrite the annotation as a dummy uniform-time .phn of LH39 phonemes so
285
+ # the stock English/phoneme aligner runs on them (no further G2P).
286
+ ann_ext = "phn"
287
+ ann_path = os.path.join(workspace, f"{base}.{ann_ext}")
288
+ audio_len = audio.shape[1]
289
+ interval = audio_len / max(1, len(phons))
290
+ with open(ann_path, "w") as f:
291
+ for i, ph in enumerate(phons):
292
+ f.write(f"{int(i * interval)} {int((i + 1) * interval)} {ph}\n")
293
+ language = "english" # phonemes are already LH39; skip the internal G2P
294
+
295
+ progress(0.3, desc="Running alignment...")
296
+ try:
297
+ with _inference_lock:
298
+ utils.set_dp_matrix_out_dir(workspace)
299
+ pred_bound, _truth_bound, mapped_ph = main_predict(
300
+ wav=wav_path,
301
+ ckpt=ckpt_to_use,
302
+ w_phi=w_phi,
303
+ language=language,
304
+ annotation=ann_ext,
305
+ )
306
+ utils.set_dp_matrix_out_dir(None)
307
+ progress(0.6, desc="Rendering aligned visualization...")
308
+ # Time-aligned representations as one stacked figure (waveform,
309
+ # spectrogram, phoneme posteriors, Soft-DP matrix + path, contrastive
310
+ # score) — all on the same time axis with predicted boundaries overlaid.
311
+ panels_path = os.path.join(workspace, "panels.png")
312
+ try:
313
+ import falcon_viz
314
+ falcon_viz.make_alignment_panels(
315
+ wav=wav_path, ckpt=ckpt_to_use, out_path=panels_path,
316
+ language=language, annotation=ann_ext,
317
+ show_truth=(original_ext == "phn" and mode == "phoneme"),
318
+ )
319
+ except Exception as viz_exc:
320
+ print(f"[FALCON] panel viz failed: {viz_exc}")
321
+ panels_path = None
322
+ except Exception as exc:
323
+ utils.set_dp_matrix_out_dir(None)
324
+ return (f"Inference error: {exc}", *OUTPUTS_NONE)
325
+
326
+ # When the app did the word-level G2P itself, use its per-word LH39 phoneme
327
+ # lists for the two-table / TextGrid word tier (main_predict's English path
328
+ # returns mapped_ph=None).
329
+ if app_mapped_ph is not None:
330
+ mapped_ph = app_mapped_ph
331
+
332
+ progress(0.8, desc="Building outputs...")
333
+
334
+ pred_bound_list = [float(t) for t in pred_bound]
335
+
336
+ # ── Build two tables ────────────────────────���────────────────────────────
337
+ # 1) LH39 phonemes — the aligner's direct output, one row per pred_bound.
338
+ # 2) Original tokens — words (.wrd / .txt) or non-LH39 phonemes (multilingual
339
+ # .phn). Only populated when the G2P path was used (mapped_ph != None);
340
+ # for english+phoneme+.phn the LH39 phonemes ARE the original, so the
341
+ # second table is left empty.
342
+ # Every input token gets a row in the table even if its predicted interval
343
+ # is degenerate; degenerate intervals are still kept out of the TextGrid
344
+ # (Praat rejects zero-length).
345
+
346
+ if mapped_ph is not None:
347
+ phn_labels = [ph for seq in mapped_ph for ph in seq]
348
+ else:
349
+ phn_labels = orig_tokens
350
+
351
+ table_phonemes, phn_intervals = [], []
352
+ t0 = 0.0
353
+ for i, t1 in enumerate(pred_bound_list):
354
+ lbl = phn_labels[i] if i < len(phn_labels) else ""
355
+ table_phonemes.append([round(t0, 3), round(t1, 3), lbl])
356
+ if t1 > t0:
357
+ phn_intervals.append((t0, t1, lbl))
358
+ t0 = t1
359
+
360
+ table_original, orig_intervals = [], []
361
+ if mapped_ph is not None:
362
+ counts_per_token = [len(seq) for seq in mapped_ph]
363
+ cumulative = 0
364
+ t0 = 0.0
365
+ for i, count in enumerate(counts_per_token):
366
+ cumulative += count
367
+ if cumulative - 1 >= len(pred_bound_list):
368
+ break
369
+ t1 = pred_bound_list[cumulative - 1]
370
+ lbl = orig_tokens[i] if i < len(orig_tokens) else ""
371
+ table_original.append([round(t0, 3), round(t1, 3), lbl])
372
+ if t1 > t0:
373
+ orig_intervals.append((t0, t1, lbl))
374
+ t0 = t1
375
+
376
+ # ── TextGrid: phones tier always, original tier when applicable ──────────
377
+ max_time = phn_intervals[-1][1] if phn_intervals else 0.0
378
+ tg = textgrid.TextGrid(minTime=0, maxTime=max_time)
379
+ tier_phn = textgrid.IntervalTier(name="phones", minTime=0, maxTime=max_time)
380
+ for t0_iv, t1_iv, lbl_iv in phn_intervals:
381
+ tier_phn.add(minTime=t0_iv, maxTime=t1_iv, mark=lbl_iv)
382
+ tg.append(tier_phn)
383
+ if orig_intervals:
384
+ # Tier name reflects what the original layer represents.
385
+ if mode == "word":
386
+ orig_tier_name = "words"
387
+ elif original_ext == "phn":
388
+ orig_tier_name = "phones_original"
389
+ else:
390
+ orig_tier_name = "tokens"
391
+ tier_orig = textgrid.IntervalTier(name=orig_tier_name, minTime=0, maxTime=max_time)
392
+ for t0_iv, t1_iv, lbl_iv in orig_intervals:
393
+ tier_orig.add(minTime=t0_iv, maxTime=t1_iv, mark=lbl_iv)
394
+ tg.append(tier_orig)
395
+ tg_path = os.path.join(workspace, f"{base}.TextGrid")
396
+ tg.write(tg_path)
397
+
398
+ # Status note: the .phn-as-word case produces a second "words" table that
399
+ # actually contains phonemes — surface this in the status so it's not
400
+ # mistaken for a bug.
401
+ status_note = word_g2p_note
402
+ if mode == "word" and original_ext == "phn":
403
+ status_note += (" Note: input was phoneme-level (.phn) but mode=word "
404
+ "— the 'original' table shows input phonemes since no "
405
+ "word annotations were provided.")
406
+
407
+ return (
408
+ "Done." + status_note,
409
+ audio_file,
410
+ panels_path if panels_path and os.path.exists(panels_path) else None,
411
+ tg_path,
412
+ table_phonemes,
413
+ table_original,
414
+ )
415
+
416
+ # ── UI ────────────────────────────────────────────────────────────────────────
417
+
418
+ PRETRAINED_RADIO_CHOICES = [
419
+ (CKPT_LABELS["english"], "english"),
420
+ (CKPT_LABELS["buckeye"], "buckeye"),
421
+ (CKPT_LABELS["multilingual"], "multilingual"),
422
+ ]
423
+
424
+ with gr.Blocks(title="FALCON Forced Aligner", theme=gr.themes.Soft()) as demo:
425
+ gr.Markdown("# FALCON: Forced Alignment through Contrastive Optimization Networks")
426
+ gr.Markdown(
427
+ "Upload a speech file and a transcript to predict precise phoneme or word boundaries "
428
+ "using Soft Dynamic Programming."
429
+ )
430
+
431
+ with gr.Row():
432
+ with gr.Column(scale=1):
433
+ gr.Markdown("### Inputs")
434
+ audio_in = gr.Audio(label="Audio file (any sample rate)", type="filepath")
435
+ ann_in = gr.File(label="Annotation (.phn / .wrd / .txt)")
436
+ mode_in = gr.Radio(["phoneme", "word"], value="phoneme", label="Mode")
437
+ lang_in = gr.Radio(["english", "multilingual"], value="english", label="Language")
438
+ # Word-level G2P front-end (word mode only). The espeak voice / MFA
439
+ # dictionary is chosen automatically from the optional input-language
440
+ # hint below; "Auto" also picks the best available backend.
441
+ g2p_in = gr.Radio(
442
+ ["Auto (recommended)", "espeak", "MFA-like",
443
+ "none — romanization (not recommended; only for languages with no G2P model)"],
444
+ value="Auto (recommended)",
445
+ label="Word G2P (used only in word mode)",
446
+ )
447
+ lang_g2p_in = gr.Dropdown(
448
+ G2P_LANG_CHOICES,
449
+ value="English (default)",
450
+ label="Input language (optional, recommended — improves G2P choice)",
451
+ )
452
+ pretrained_in = gr.Radio(
453
+ choices=PRETRAINED_RADIO_CHOICES,
454
+ value="english",
455
+ label="Pretrained checkpoint (auto-follows Language; override here if you want)",
456
+ )
457
+ ckpt_in = gr.File(label="Or upload a custom checkpoint (.pt) — overrides pretrained",
458
+ file_types=[".pt"], type="filepath")
459
+ wphi_in = gr.Slider(0.0, 1.0, value=0.5, step=0.01,
460
+ label="φ weight (acoustic ↔ linguistic)")
461
+ btn = gr.Button("Run Alignment", variant="primary")
462
+ status = gr.Textbox(label="Status", interactive=False)
463
+
464
+ with gr.Column(scale=2):
465
+ gr.Markdown("### Outputs")
466
+ with gr.Tabs():
467
+ with gr.Tab("Alignment Data"):
468
+ audio_out = gr.Audio(label="Playback", interactive=False)
469
+ table_phn_out = gr.Dataframe(
470
+ headers=["Start (s)", "End (s)", "Phoneme (LH39)"],
471
+ label="LH39 phonemes — aligner output",
472
+ )
473
+ table_orig_out = gr.Dataframe(
474
+ headers=["Start (s)", "End (s)", "Label"],
475
+ label="Original input layer (words / non-LH39 phonemes) — empty for English phoneme alignment",
476
+ )
477
+ tg_out = gr.File(label="Download TextGrid (carries both tiers when applicable)")
478
+ with gr.Tab("Visualizations"):
479
+ img_panels = gr.Image(
480
+ label="Time-aligned representations — waveform · spectrogram · phoneme posteriors · Soft-DP path · contrastive score (shared time axis; predicted boundaries overlaid). Click to enlarge.",
481
+ show_download_button=True,
482
+ )
483
+
484
+ # Auto-flip the pretrained-checkpoint radio when the user changes language.
485
+ lang_in.change(fn=lambda v: v, inputs=lang_in, outputs=pretrained_in)
486
+
487
+ btn.click(
488
+ fn=run_alignment,
489
+ inputs=[audio_in, ann_in, ckpt_in, mode_in, lang_in, pretrained_in, wphi_in,
490
+ g2p_in, lang_g2p_in],
491
+ outputs=[status, audio_out, img_panels,
492
+ tg_out, table_phn_out, table_orig_out],
493
+ )
494
+
495
+ # Auto-shutdown the local Python server when the user closes the browser
496
+ # tab. Disabled on HuggingFace Spaces (where SPACE_ID is set automatically)
497
+ # because the container is shared across visitors — one tab close should
498
+ # not tear down everyone else's session.
499
+ if not os.environ.get("SPACE_ID"):
500
+ # Fast path: unload events click a hidden Shutdown button -> os._exit.
501
+ shutdown_btn = gr.Button("Shutdown", visible=False, elem_id="falcon-shutdown-btn")
502
+ shutdown_btn.click(fn=lambda: os._exit(0), inputs=[], outputs=[])
503
+
504
+ # Guaranteed fallback: the page heartbeats; the watchdog exits if it stops.
505
+ hb_btn = gr.Button("hb", visible=False, elem_id="falcon-heartbeat-btn")
506
+ hb_btn.click(fn=_heartbeat, inputs=[], outputs=[],
507
+ show_progress="hidden", queue=False)
508
+ _start_shutdown_watchdog()
509
+
510
+ demo.load(None, None, None, js="""
511
+ () => {
512
+ const stop = () => {
513
+ const btn = document.getElementById('falcon-shutdown-btn');
514
+ if (btn) btn.click();
515
+ };
516
+ window.addEventListener('beforeunload', stop);
517
+ window.addEventListener('pagehide', stop);
518
+ const beat = () => {
519
+ const hb = document.getElementById('falcon-heartbeat-btn');
520
+ if (hb) hb.click();
521
+ };
522
+ beat();
523
+ setInterval(beat, 10000);
524
+ }
525
+ """)
526
+
527
+ if __name__ == "__main__":
528
+ demo.launch()
assets/fasw0sa2.phn ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 0 2200 h#
2
+ 2200 2510 d
3
+ 2510 5229 ow
4
+ 5229 5576 n
5
+ 5576 7000 q
6
+ 7000 9460 ae
7
+ 9460 10480 s
8
+ 10480 11760 epi
9
+ 11760 12640 m
10
+ 12640 14720 iy
11
+ 14720 15550 tcl
12
+ 15550 16000 t
13
+ 16000 16760 ix
14
+ 16760 18070 kcl
15
+ 18070 19050 k
16
+ 19050 20804 eh
17
+ 20804 22099 r
18
+ 22099 23979 iy
19
+ 23979 25560 eh
20
+ 25560 26223 n
21
+ 26223 26674 ax
22
+ 26674 27897 q
23
+ 27897 30161 ao
24
+ 30161 31080 l
25
+ 31080 32292 iy
26
+ 32292 34339 r
27
+ 34339 38440 ae
28
+ 38440 39160 gcl
29
+ 39160 39530 g
30
+ 39530 40393 l
31
+ 40393 42120 ay
32
+ 42120 44040 kcl
33
+ 44040 44864 dh
34
+ 44864 48680 ae
35
+ 48680 49660 tcl
36
+ 49660 50640 h#
assets/fasw0sa2.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 0 50688 Don't ask me to carry an oily rag like that.
assets/fasw0sa2.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:32a6bfe7791589a1467ee7993de348bf249ef05186fe97e215d379db85670f7e
3
+ size 101420
assets/fasw0sa2.wrd ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ 2200 7000 don't
2
+ 7000 10480 ask
3
+ 11760 14720 me
4
+ 14720 16760 to
5
+ 16760 23979 carry
6
+ 23979 26223 an
7
+ 26223 32292 oily
8
+ 32292 39530 rag
9
+ 39530 44040 like
10
+ 44040 49660 that
dataloader.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+ from torch.utils.data import DataLoader, Dataset
5
+ torch.multiprocessing.set_sharing_strategy('file_system')
6
+ from tqdm import tqdm
7
+ import numpy as np
8
+ import os
9
+ from os.path import join, basename
10
+ from boltons.fileutils import iter_find_files
11
+ import soundfile as sf
12
+ import librosa
13
+ import pickle
14
+ from multiprocessing import Pool
15
+ import random
16
+ import torchaudio
17
+ import math
18
+ from torchaudio.datasets import LIBRISPEECH
19
+
20
+
21
+ def collate_fn_padd(batch):
22
+ """collate_fn_padd
23
+ Padds batch of variable length
24
+
25
+ :param batch:
26
+ """
27
+ # get sequence lengths
28
+ spects = [t[0] for t in batch]
29
+ segs = [t[1] for t in batch]
30
+ labels = [t[2] for t in batch]
31
+ lengths = [t[3] for t in batch]
32
+ fnames = [t[4] for t in batch]
33
+
34
+ padded_spects = torch.nn.utils.rnn.pad_sequence(spects, batch_first=True)
35
+ lengths = torch.LongTensor(lengths)
36
+ return padded_spects, segs, labels, lengths, fnames
37
+
38
+
39
+ def spectral_size(wav_len):
40
+ layers = [(10,5,0), (8,4,0), (4,2,0), (4,2,0), (4,2,0)]
41
+ for kernel, stride, padding in layers:
42
+ wav_len = math.floor((wav_len + 2*padding - 1*(kernel-1) - 1)/stride + 1)
43
+ return wav_len
44
+
45
+
46
+ def get_subset(dataset, percent):
47
+ A_split = int(len(dataset) * percent)
48
+ B_split = len(dataset) - A_split
49
+ dataset, _ = torch.utils.data.random_split(dataset, [A_split, B_split])
50
+ return dataset
51
+
52
+
53
+ class WavPhnDataset(Dataset):
54
+ def __init__(self, path):
55
+ self.path = path
56
+ self.data = list(iter_find_files(self.path, "*.wav"))
57
+
58
+ def process_file(self, wav_path):
59
+
60
+ phn_path = wav_path[:-4] + ".phn"
61
+
62
+ # load audio
63
+ audio, sr = torchaudio.load(wav_path)
64
+ audio = audio[0]
65
+ audio_len = len(audio)
66
+ spectral_len = spectral_size(audio_len)
67
+ len_ratio = (audio_len / spectral_len)
68
+
69
+ # load labels -- segmentation and phonemes
70
+ with open(phn_path, "r") as f:
71
+ lines = f.readlines()
72
+ lines = list(map(lambda line: line.split(" "), lines))
73
+
74
+ # get segment times
75
+ times = torch.FloatTensor(list(map(lambda line: int(int(line[1]) / len_ratio), lines)))[:-1] # don't count end time as boundary
76
+
77
+ # get phonemes in each segment (for K times there should be K+1 phonemes)
78
+ phonemes = list(map(lambda line: line[2].strip(), lines))
79
+
80
+ return audio, times.tolist(), phonemes, wav_path
81
+
82
+ def __getitem__(self, idx):
83
+ audio, seg, phonemes, fname = self.process_file(self.data[idx])
84
+ audio_len = len(audio)
85
+ spectral_len = spectral_size(audio_len)
86
+ len_ratio = (audio_len / spectral_len)
87
+ return audio, seg, phonemes, audio_len/len_ratio, fname
88
+
89
+ def __len__(self):
90
+ return len(self.data)
91
+
92
+ class TrainTestDataset(WavPhnDataset):
93
+ @staticmethod
94
+ def get_datasets(path, val_ratio=0.1, overlap=False, seed: int = 42):
95
+ """
96
+ If overlap==False (default) split train into disjoint train/val (random_split).
97
+ If overlap==True create val as a Subset sampled from the train dataset
98
+ but keep train_dataset as the full set (so val files are also seen in training).
99
+ """
100
+ train_full = TrainTestDataset(join(path, 'train'))
101
+ test_dataset = TrainTestDataset(join(path, 'test'))
102
+ train_len = len(train_full)
103
+
104
+ val_size = int(train_len * val_ratio)
105
+ if val_size <= 0:
106
+ # no validation
107
+ return train_full, None, test_dataset
108
+
109
+ if overlap:
110
+ rng = random.Random(seed)
111
+ val_indices = rng.sample(range(train_len), val_size)
112
+ val_dataset = torch.utils.data.Subset(train_full, val_indices)
113
+ train_dataset = train_full # full training set (contains val files)
114
+ else:
115
+ # exclusive split (current behavior)
116
+ gen = torch.Generator()
117
+ gen.manual_seed(seed)
118
+ train_split = train_len - val_size
119
+ train_dataset, val_dataset = torch.utils.data.random_split(train_full, [train_split, val_size], generator=gen)
120
+ # keep .path attribute for compatibility
121
+ train_dataset.path = join(path, 'train')
122
+ val_dataset.path = join(path, 'train')
123
+ return train_dataset, val_dataset, test_dataset
124
+
125
+ # ensure compatibility of .path attribute
126
+ train_dataset.path = join(path, 'train')
127
+ val_dataset.path = join(path, 'train')
128
+ return train_dataset, val_dataset, test_dataset
129
+
130
+
131
+ class TrainValTestDataset(WavPhnDataset):
132
+ @staticmethod
133
+ def get_datasets(path, percent=1.0):
134
+ train_dataset = TrainValTestDataset(join(path, 'train'))
135
+ if percent != 1.0:
136
+ train_dataset = get_subset(train_dataset, percent)
137
+ train_dataset.path = join(path, 'train')
138
+ val_dataset = TrainValTestDataset(join(path, 'val'))
139
+ test_dataset = TrainValTestDataset(join(path, 'test'))
140
+
141
+ return train_dataset, val_dataset, test_dataset
dutch_preprocess.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import panphon
3
+ import panphon.distance
4
+
5
+ ft = panphon.FeatureTable()
6
+ dst = panphon.distance.Distance()
7
+
8
+ # IFA_TO_IPA = TODO - FUNCTION THAT COMPUTES SCORE
9
+ # NOT MAPPING BUT NEEDS TO BE A PUNCTION
10
+
11
+ # IPA_TO_LEEHON39 = TODO - NOT MAPPING NEEDS TO BE A FUNCTION BUT ONE TIME - MAYBE IT IS MAPPING?
12
+
13
+ IFA_TO_IPA = {
14
+ "p":"p", "b":"b", "t":"t", "d":"d", "k":"k", "g":"ɡ",
15
+ "f":"f", "v":"v", "s":"s", "z":"z", "h":"h", "x":"x", "G":"ɣ",
16
+ "m":"m", "n":"n", "N":"ŋ", "l":"l", "r":"r", "w":"ʋ", "j":"j",
17
+ "S":"ʃ", "Z":"ʒ", "J":"ɲ", "L":"ʎ",
18
+ "i":"i", "I":"ɪ", "e":"eː", "E":"ɛ", "a":"aː", "A":"ɑ",
19
+ "o":"oː", "O":"ɔ", "u":"u", "y":"y", "Y":"ʏ", "2":"øː",
20
+ "9":"œ", "@":"ə", "!" : "ɛi", "V" : "ʌu", "W" : "œy", "h#" : "h#"
21
+ }
22
+
23
+ LH39_IPA = {
24
+ "AA": "ɑ", "AE": "æ", "AH": "ʌ", "AO": "ɔ", "AW": "aʊ", "AY": "aɪ",
25
+ "EH": "ɛ", "ER": "ɝ", "EY": "eɪ", "IH": "ɪ", "IY": "i", "OW": "oʊ",
26
+ "OY": "ɔɪ", "UH": "ʊ", "UW": "u", "B": "b", "CH": "tʃ", "D": "d",
27
+ "DH": "ð", "F": "f", "G": "ɡ", "HH": "h", "JH": "dʒ", "K": "k",
28
+ "L": "l", "M": "m", "N": "n", "NG": "ŋ", "P": "p", "R": "ɹ",
29
+ "S": "s", "SH": "ʃ", "T": "t", "TH": "θ", "V": "v", "W": "w",
30
+ "Y": "j", "Z": "z", "ZH": "ʒ"
31
+ }
32
+
33
+ timit_leehon_39_phonemes = [
34
+ 'ao', 'ae', 'ah','aw', 'er', 'ay',
35
+ 'b', 'sil', 'ch', 'd', 'dh', 'dx', 'eh', 'el', 'm', 'en', 'ng', 'ey',
36
+ 'f', 'g', 'hh', 'ih', 'iy', 'jh', 'k', 'v', 'w', 'y', 'z', 'sh', 't', 'r', 's', 'th','uh', 'uw', 'oy', 'ow','p'
37
+ ]
38
+
39
+ def get_ipa_from_ifa(ifa_label):
40
+
41
+ if ifa_label.lower() in timit_leehon_39_phonemes:
42
+ return [ifa_label.lower()]
43
+ if ifa_label in ['h#', 'tcl']:
44
+ # if ifa_label in ['h#']:
45
+ return ["sil"]
46
+ # if ifa_label in ['tcl']:
47
+ # return []
48
+
49
+ # Convert underscores and hyphens to spaces, and remove colons (length is handled by the base vowel mapping or discarded)
50
+ cleaned = ifa_label.replace(':', '').replace('_', ' ').replace('-', ' ') #.replace('tcl', ' ')
51
+ # Remove Stress ("), Secondary Stress ('), Syllable dots (.), and nasal tildes (~)
52
+ cleaned = re.sub(r'[".\'~]', '', cleaned)
53
+ # parts = cleaned.split()
54
+ # parts = cleaned.strip()
55
+ parts = cleaned.strip().split()
56
+ if not parts:
57
+ return []
58
+ if len(parts) == 1 and len(ifa_label) >1 and ifa_label not in IFA_TO_IPA: #it's a long phoneme label that needs to be splitted to several IPA symbols
59
+ parts = list(ifa_label)
60
+
61
+ ipa_list = [IFA_TO_IPA.get(p,p) for p in parts if p.strip()]
62
+ return ipa_list
63
+
64
+ _leehon39_cache = {}
65
+
66
+ def find_best_leehon39(target_ipa):
67
+ # Deterministic IPA->LH39 mapping; memoize since the panphon feature-edit
68
+ # distance over a fixed inventory recomputes the same answer per occurrence.
69
+ if target_ipa in _leehon39_cache:
70
+ return _leehon39_cache[target_ipa]
71
+ result = _find_best_leehon39(target_ipa)
72
+ _leehon39_cache[target_ipa] = result
73
+ return result
74
+
75
+ def _find_best_leehon39(target_ipa):
76
+
77
+ if not target_ipa or target_ipa.strip() == "":
78
+ return "sil", 0.0
79
+
80
+ if target_ipa.lower() in timit_leehon_39_phonemes:
81
+ return target_ipa.lower(), 0.0
82
+
83
+ # if target_ipa.lower() in ['h#', 'tcl', 'sil']:
84
+ if target_ipa.lower() in ['h#', 'sil']:
85
+ return "sil", 0.0
86
+ if target_ipa.lower() in ["r", "ɾ"]:
87
+ return "r", 0.0
88
+
89
+ best_label = "sil"
90
+ min_dist = 100.0
91
+
92
+ for lh_label, lh_ipa in LH39_IPA.items():
93
+
94
+ d = dst.feature_edit_distance(target_ipa, lh_ipa)
95
+ if d< min_dist:
96
+ min_dist = d
97
+ best_label = lh_label.lower()
98
+ return best_label, round(min_dist,3)
99
+
100
+ def aligner_pipeline(ifa_input):
101
+ ifa_segments = get_ipa_from_ifa(ifa_input)
102
+ results = []
103
+
104
+ for ipa_seg in ifa_segments:
105
+ match, d = find_best_leehon39(ipa_seg)
106
+ results.append( {"ifa_ipa_part" :ipa_seg, "lh39" :match, "dist" :d} )
107
+ return results
108
+
109
+
110
+ import os
111
+
112
+ # def convert_all_lab_files(directory):
113
+ # for filename in os.listdir(directory):
114
+ # if filename.endswith(".lab"):
115
+ # path = os.path.join(directory, filename)
116
+ # with open(path, 'r') as f:
117
+ # content = f.read().strip()
118
+
119
+ # # Use your existing pipeline logic
120
+ # # Note: We split the content by space to process each phone
121
+ # ifa_phones = content.split()
122
+ # ipa_output = []
123
+ # for p in ifa_phones:
124
+ # # Get the IPA parts from your existing function
125
+ # ipa_parts = get_ipa_from_ifa(p)
126
+ # ipa_output.extend(ipa_parts)
127
+
128
+ # # Join with spaces and write back
129
+ # new_content = " ".join(ipa_output)
130
+ # with open(path, 'w') as f:
131
+ # f.write(new_content)
132
+ # print(f"Done! All .lab files in {directory} converted to IPA.")
133
+
134
+ # # Run this in your main block
135
+ # # convert_all_lab_files('/home/rotem/projects/datasets/IFA_dutch_split/test')
136
+
137
+ # # convert_all_lab_files('/home/rotem/projects/datasets/IFA_dutch_split/test')
138
+
139
+
140
+
141
+ import os
142
+
143
+ def create_lab_files(phn_folder, lab_folder):
144
+ if not os.path.exists(lab_folder):
145
+ os.makedirs(lab_folder)
146
+
147
+ for filename in os.listdir(phn_folder):
148
+ if filename.endswith(".phn"):
149
+ with open(os.path.join(phn_folder, filename), 'r') as f:
150
+ lines = f.readlines()
151
+
152
+ ipa_sequence = []
153
+ for line in lines:
154
+ parts = line.strip().split()
155
+ if len(parts) < 3: continue
156
+
157
+ label = parts[2]
158
+ # Use your existing mapping function
159
+ ipa_symbols = get_ipa_from_ifa(label)
160
+
161
+ # Filter out 'sil' if you want MFA to handle silence automatically,
162
+ # but usually keeping them is fine for phone-level alignment.
163
+ ipa_sequence.extend(ipa_symbols)
164
+
165
+ # Save to .lab file (space separated string)
166
+ lab_filename = filename.replace(".phn", ".lab")
167
+ with open(os.path.join(lab_folder, lab_filename), 'w') as f:
168
+ f.write(" ".join(ipa_sequence))
169
+
170
+ def generate_ipa_lexicon(all_ipa_symbols, output_path):
171
+ with open(output_path, 'w') as f:
172
+ # Add a silence mapping just in case
173
+ f.write("sil\tsil\n")
174
+ # Map every unique IPA symbol to itself
175
+ for symbol in sorted(list(set(all_ipa_symbols))):
176
+ if symbol != "sil":
177
+ f.write(f"{symbol}\t{symbol}\n")
178
+
179
+ # Run it
180
+ # create_lab_files("/home/rotem/projects/datasets/IFA_dutch_split/test", "/home/rotem/projects/datasets/IFA_dutch_split/test")
181
+
182
+ if __name__ == "__main__":
183
+ test_cases = ["sil n Y l sil e: n sil t w e: sil d r i sil v i r sil v Ei f sil z E s sil z e: v @ n sil A x t sil n e: x @ sil t i n sil E l f sil t w a: l f sil n Y l sil sil"]
184
+ # test_cases = ["x@l", "@-r-h-a", "e:-j", "r9y", "ao", "sil", "@", "E", "he:l-@_hAr", "t_b", "o:", "N"]
185
+ for case in test_cases:
186
+ print(f"\nINPUT: {case}")
187
+ output = aligner_pipeline(case)
188
+ # [x["lh39"] for x in output]
189
+ if not output:
190
+ print("Results: None")
191
+ else:
192
+ for item in output:
193
+ print(f" Mapped '{item['ifa_ipa_part']}' -> {item['lh39']} (dist_score: {item['dist']})")
194
+ # convert_all_lab_files('/home/rotem/projects/datasets/IFA_dutch_split/test')
195
+ # Run it
196
+ create_lab_files("/home/rotem/projects/datasets/IFA_dutch_split/test", "/home/rotem/projects/datasets/IFA_dutch_split/test")
197
+
falcon_viz.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ falcon_viz.py — Time-aligned alignment visualizations for FALCON.
3
+
4
+ Two entry points, both sharing the same per-panel drawers so the styling stays
5
+ identical:
6
+
7
+ make_alignment_panels(wav, ckpt, out_path, ...)
8
+ -> ONE tall figure, 5 panels stacked on a shared time axis (for the
9
+ README / a downloadable overview).
10
+
11
+ make_alignment_separate(wav, ckpt, out_dir, ...)
12
+ -> a LIST of (path, caption), one wide, full-size figure per panel (for
13
+ the web app, where a single stacked figure renders too small to read).
14
+
15
+ Panels: waveform, log-mel spectrogram, phoneme posteriors, Soft-DP cost matrix +
16
+ alignment path, and the contrastive boundary score with its detected peaks. The
17
+ same predicted boundaries (crimson) and truth boundaries (charcoal) are drawn on
18
+ every panel so the time alignment is visually verifiable.
19
+
20
+ This module does NOT modify any model/training file. It re-runs the model forward
21
+ (replicating predict.py:main_predict) and reproduces utils.phoneme_alignment's DP
22
+ matrix locally (re-using utils.compute_phi_1 / compute_phi_2 unchanged).
23
+ """
24
+
25
+ import os
26
+ from glob import glob
27
+
28
+ import numpy as np
29
+ import torch
30
+ import torch.nn.functional as F
31
+ import torchaudio
32
+ import matplotlib
33
+
34
+ matplotlib.use("Agg")
35
+ import matplotlib.pyplot as plt
36
+ from matplotlib.gridspec import GridSpec
37
+
38
+ from dataloader import spectral_size
39
+ from predict import _load_model
40
+ from utils import (
41
+ max_min_norm,
42
+ get_timit_61_phoneme_mappings,
43
+ compute_phi_1,
44
+ compute_phi_2,
45
+ phoneme_to_idx_MACRO,
46
+ timit_to_leehon_map_MACRO,
47
+ timit_leehon_39_phonemes,
48
+ )
49
+
50
+ # Hard-coded in the model forward (next_frame_classifier.py) — CNN total stride.
51
+ # One latent frame ~= len_ratio audio samples ~= 10 ms at 16 kHz.
52
+ LEN_RATIO = 161.34011627906978
53
+ SR = 16000
54
+
55
+ # Light / pastel palette so the crimson boundary overlays stand out.
56
+ PRED_COLOR = "crimson"
57
+ TRUTH_COLOR = "#37474f" # charcoal (distinct from the blue/purple cmaps)
58
+ WAVE_COLOR = "#3b6fb6" # blue (matches the example waveform figure)
59
+ SCORE_COLOR = "#3a7ca5"
60
+ SPEC_CMAP = "viridis"
61
+ POST_CMAP = "viridis"
62
+ DP_CMAP = "viridis"
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # DP matrix reconstruction (mirrors utils.phoneme_alignment, returns matrix) #
67
+ # --------------------------------------------------------------------------- #
68
+ def _build_dp_matrix(p_seq, w_phi, original_lengths, derivative_preds_np, probs_real):
69
+ """Re-run the exact Soft-DP forward + soft-argmax backtrack from
70
+ utils.phoneme_alignment, additionally returning the DP matrix so it can be
71
+ plotted (the stock function only returns the path)."""
72
+ gamma = 1e-20
73
+ T = int(original_lengths[0])
74
+ n = len(p_seq)
75
+ device = derivative_preds_np.device
76
+
77
+ if isinstance(probs_real, np.ndarray):
78
+ probs_real = torch.tensor(probs_real, device=device)
79
+ cumsum_probs = torch.cumsum(probs_real, dim=0)
80
+
81
+ phoneme_mappings = {
82
+ p.lower(): timit_to_leehon_map_MACRO.get(p.lower(), "sil")
83
+ if p.lower() not in timit_leehon_39_phonemes
84
+ else p.lower()
85
+ for p in p_seq
86
+ }
87
+ derivatives = torch.cat(
88
+ [torch.tensor([0], device=device), torch.diff(derivative_preds_np, dim=0)]
89
+ )
90
+
91
+ dp_mat = torch.full((n, T, T), float(-1e9), device=device)
92
+
93
+ t_e = torch.arange(T, device=device)
94
+ dp_mat[0, 0, :] = (
95
+ w_phi[0] * compute_phi_1(derivatives, 0, t_e)
96
+ + w_phi[1] * compute_phi_1(derivatives, 0, t_e)
97
+ )
98
+
99
+ for i in range(1, n):
100
+ p_idx = phoneme_to_idx_MACRO[phoneme_mappings[p_seq[i].lower()]]
101
+ t_start = torch.arange(T, device=device)
102
+ t_end = torch.arange(T, device=device)
103
+ t_start_grid, t_end_grid = torch.meshgrid(t_start, t_end, indexing="ij")
104
+ valid_mask = t_start_grid < t_end_grid
105
+
106
+ phi1_dev = compute_phi_1(derivatives, t_start_grid, t_end_grid)
107
+ phi2 = compute_phi_2(cumsum_probs, p_idx, t_start_grid, t_end_grid)
108
+ total_phi = w_phi[0] * phi1_dev + w_phi[1] * phi2
109
+
110
+ col_lse = torch.logsumexp(dp_mat[i - 1] / gamma, dim=0) * gamma
111
+ prev_scores = torch.where(
112
+ valid_mask,
113
+ col_lse.unsqueeze(1).expand(T, T),
114
+ torch.full((T, T), float(-1e9), device=device),
115
+ )
116
+ dp_mat[i] = torch.where(
117
+ valid_mask, total_phi + prev_scores, torch.full_like(total_phi, float(-1e9))
118
+ )
119
+
120
+ best_start_times = torch.zeros((n), dtype=derivative_preds_np.dtype, device=device)
121
+ best_prev_t_end = T - 1
122
+ for i in range(n):
123
+ cur_ph = n - 1 - i
124
+ scores = dp_mat[cur_ph, :, best_prev_t_end]
125
+ soft_weights = torch.softmax(scores / gamma, dim=0)
126
+ expected_idx = (
127
+ soft_weights
128
+ * torch.arange(T, device=device, dtype=derivative_preds_np.dtype)
129
+ ).sum()
130
+ best_start_times[cur_ph] = expected_idx
131
+ best_prev_t_end = int(expected_idx.round().item())
132
+
133
+ dp_to_plot = dp_mat.detach().cpu().max(dim=1)[0].numpy() # (n, T)
134
+ best_start_frames = best_start_times.detach().cpu().numpy()
135
+ return dp_to_plot, best_start_frames
136
+
137
+
138
+ # --------------------------------------------------------------------------- #
139
+ # Forward pass + array extraction (replicates predict.py:main_predict) #
140
+ # --------------------------------------------------------------------------- #
141
+ def _extract_arrays(wav, ckpt, annotation):
142
+ model, _peak_params = _load_model(ckpt)
143
+ model.eval()
144
+
145
+ audio, sr = torchaudio.load(wav)
146
+ assert sr == SR, "model expects 16 kHz audio"
147
+ audio = audio[0]
148
+ audio_len = len(audio)
149
+ spectral_len = spectral_size(audio_len)
150
+ len_ratio = audio_len / spectral_len # ~= LEN_RATIO
151
+
152
+ base_dir = os.path.dirname(wav)
153
+ base_name = os.path.basename(wav).split(".")[0]
154
+ matches = glob(os.path.join(base_dir, f"{base_name}*.{annotation}"))
155
+ phn_path = matches[0] if matches else wav.replace("wav", "phn")
156
+
157
+ with open(phn_path, "r") as f:
158
+ lines = [ln.split() for ln in f.readlines()]
159
+ truth_secs = [float(ln[1]) / SR for ln in lines][:-1]
160
+ phonemes = [ln[2].strip() for ln in lines]
161
+ truth_labels = list(phonemes)
162
+
163
+ length = [audio_len / len_ratio]
164
+ with torch.no_grad():
165
+ preds, original_lengths, probs, frame_labels, seg, total_peaks, w_phi = model(
166
+ audio.unsqueeze(0), None, [phonemes], length
167
+ )
168
+
169
+ # Contrastive / latent boundary score (predict.py 168-172).
170
+ p = preds[1][0]
171
+ p = max_min_norm(p)
172
+ p_np = p.detach().numpy()[0]
173
+ p_np = p_np - np.median(p_np)
174
+
175
+ # Phoneme posteriors (predict.py 143).
176
+ probs_real = F.softmax(probs, dim=-1).squeeze(0).detach().numpy() # (T, 39)
177
+ _, idx_to_phoneme = get_timit_61_phoneme_mappings()
178
+ phoneme_labels = [idx_to_phoneme[i] for i in range(39)]
179
+
180
+ pred_secs = list(total_peaks[0])
181
+
182
+ # Phoneme labels at predicted-segment midpoints (segment i = [bound[i],
183
+ # bound[i+1]] with bounds = predicted boundaries + the utterance end), matching
184
+ # how the TextGrid assigns each phoneme to a predicted interval.
185
+ bounds = [float(x) for x in pred_secs] + [audio_len / SR]
186
+ seg_mids = [(bounds[i] + bounds[i + 1]) / 2.0 for i in range(len(bounds) - 1)]
187
+ n_lab = min(len(seg_mids), len(phonemes))
188
+ label_mids = seg_mids[:n_lab]
189
+ label_text = phonemes[:n_lab]
190
+
191
+ # DP matrix (reproduce phoneme_alignment locally).
192
+ w_phi_vec = w_phi.detach()
193
+ deriv_arg = torch.tensor(p_np, dtype=torch.float32)
194
+ dp_to_plot, dp_path_frames = _build_dp_matrix(
195
+ phonemes, w_phi_vec, [int(original_lengths[0])], deriv_arg, probs_real
196
+ )
197
+
198
+ return dict(
199
+ audio=audio.numpy(),
200
+ sr=sr,
201
+ len_ratio=len_ratio,
202
+ duration=audio_len / SR,
203
+ latent_score=p_np,
204
+ probs_real=probs_real,
205
+ phoneme_labels=phoneme_labels,
206
+ pred_secs=pred_secs,
207
+ truth_secs=truth_secs,
208
+ truth_labels=truth_labels,
209
+ dp_to_plot=dp_to_plot,
210
+ dp_path_frames=dp_path_frames,
211
+ phonemes=phonemes,
212
+ label_mids=label_mids,
213
+ label_text=label_text,
214
+ )
215
+
216
+
217
+ # --------------------------------------------------------------------------- #
218
+ # Shared per-panel drawers #
219
+ # --------------------------------------------------------------------------- #
220
+ def _overlay_boundaries(ax, pred_secs, truth_secs, show_truth, label_first=False):
221
+ """Predicted (crimson dashed) + optional truth (charcoal dotted) lines."""
222
+ for j, t in enumerate(truth_secs if show_truth else []):
223
+ ax.axvline(t, color=TRUTH_COLOR, linestyle=":", linewidth=0.9, alpha=0.6,
224
+ label="Truth boundary" if (label_first and j == 0) else None, zorder=2)
225
+ for j, t in enumerate(pred_secs):
226
+ ax.axvline(t, color=PRED_COLOR, linestyle="--", linewidth=1.1, alpha=0.9,
227
+ label="Predicted boundary" if (label_first and j == 0) else None, zorder=3)
228
+
229
+
230
+ def _annotate_phoneme_tier(ax, d):
231
+ """Write the input phoneme labels at their predicted-segment midpoints, just
232
+ below the x-axis (a phoneme tier)."""
233
+ mids = d.get("label_mids") or []
234
+ labels = d.get("label_text") or []
235
+ trans = ax.get_xaxis_transform() # x in data coords, y in axes fraction
236
+ for m, lab in zip(mids, labels):
237
+ ax.text(m, -0.07, str(lab), transform=trans, rotation=90, ha="center",
238
+ va="top", fontsize=5.5, color="#333333", clip_on=False)
239
+
240
+
241
+ def _panel_waveform(ax, d, show_truth, label_first=True):
242
+ audio = d["audio"]
243
+ dur = d["duration"]
244
+ t = np.linspace(0, dur, num=len(audio))
245
+ ax.plot(t, audio, color=WAVE_COLOR, linewidth=0.5)
246
+ ax.set_ylabel("Amplitude")
247
+ ax.margins(x=0)
248
+ ymax = (float(np.abs(audio).max()) or 1.0) * 1.15
249
+ ax.set_ylim(-ymax, ymax)
250
+ _overlay_boundaries(ax, d["pred_secs"], d["truth_secs"], show_truth, label_first=label_first)
251
+ if label_first:
252
+ ax.legend(loc="upper right", fontsize=8, framealpha=0.9, ncol=2)
253
+
254
+
255
+ def _panel_spectrogram(ax, d, show_truth):
256
+ audio = d["audio"]
257
+ dur = d["duration"]
258
+ mel = torchaudio.transforms.MelSpectrogram(
259
+ sample_rate=SR, n_fft=400, hop_length=160, n_mels=80
260
+ )(torch.tensor(audio).float().unsqueeze(0))
261
+ mel_db = torchaudio.transforms.AmplitudeToDB(top_db=80)(mel).squeeze(0).numpy()
262
+ ax.imshow(mel_db, aspect="auto", origin="lower",
263
+ extent=[0, dur, 0, SR / 2 / 1000.0], cmap=SPEC_CMAP)
264
+ ax.set_ylabel("Freq (kHz)")
265
+ _overlay_boundaries(ax, d["pred_secs"], d["truth_secs"], show_truth)
266
+
267
+
268
+ def _panel_posteriors(ax, d, show_truth, colorbar=True):
269
+ probs_real = d["probs_real"]
270
+ dur = d["duration"]
271
+ im = ax.imshow(probs_real.T, aspect="auto", origin="lower",
272
+ extent=[0, dur, -0.5, 38.5], cmap=POST_CMAP, interpolation="nearest")
273
+ ax.set_yticks(range(39))
274
+ ax.set_yticklabels(d["phoneme_labels"], fontsize=5.5)
275
+ ax.set_ylabel("Phoneme (LH-39)")
276
+ if colorbar:
277
+ ax.figure.colorbar(im, ax=ax, label="P(phoneme)", pad=0.01, fraction=0.025)
278
+ _overlay_boundaries(ax, d["pred_secs"], d["truth_secs"], show_truth)
279
+
280
+
281
+ def _panel_dp(ax, d, show_truth, colorbar=True):
282
+ dp_to_plot = d["dp_to_plot"]
283
+ dur = d["duration"]
284
+ len_ratio = d["len_ratio"]
285
+ masked = np.ma.masked_where(dp_to_plot <= -1e8, dp_to_plot)
286
+ cmap = getattr(plt.cm, DP_CMAP).copy()
287
+ cmap.set_bad(color="white")
288
+ n_ph = dp_to_plot.shape[0]
289
+ im = ax.imshow(masked, aspect="auto", origin="lower",
290
+ extent=[0, dur, -0.5, n_ph - 0.5], cmap=cmap, interpolation="nearest")
291
+ path_secs = np.asarray(d["dp_path_frames"]) * len_ratio / SR
292
+ ax.plot(path_secs, np.arange(n_ph), color=PRED_COLOR, marker=".", markersize=4,
293
+ linewidth=1.2, label="Optimal alignment path")
294
+ ax.set_ylabel("Phoneme position")
295
+ if colorbar:
296
+ ax.figure.colorbar(im, ax=ax, label="DP score", pad=0.01, fraction=0.025)
297
+ _overlay_boundaries(ax, d["pred_secs"], d["truth_secs"], show_truth)
298
+ ax.legend(loc="lower right", fontsize=8, framealpha=0.9)
299
+
300
+
301
+ def _panel_contrastive(ax, d, show_truth):
302
+ # Boundary score (red) + its derivative (magenta), as in predict.py's run plot,
303
+ # but (1) auto-scaled robustly so the per-boundary structure is visible instead
304
+ # of being squashed by the silence->speech onset spike, and (2) without the
305
+ # redundant predicted-boundary markers — the predicted boundaries are the red
306
+ # dashed lines shared across every panel. Ground truth omitted (not available
307
+ # at inference). x-axis is time (s).
308
+ s = np.asarray(d["latent_score"], dtype=float)
309
+ len_ratio = d["len_ratio"]
310
+ n = len(s)
311
+ t = np.arange(n) * len_ratio / SR
312
+ deriv = np.concatenate([[0.0], np.diff(s)])
313
+
314
+ ax.plot(t, deriv, marker="o", markersize=1.6, linewidth=0.7, alpha=0.8,
315
+ color="magenta", label="Derivative of latent score")
316
+ ax.plot(t, s, marker="*", markersize=2.2, linewidth=0.8, color="red",
317
+ label="Latent score")
318
+
319
+ # Robust symmetric y-limit: zoom to the 96th-percentile magnitude so the small
320
+ # per-boundary structure fills the panel; the rare large onset spike clips off.
321
+ mag = np.concatenate([np.abs(s), np.abs(deriv)])
322
+ A = max(float(np.percentile(mag, 96)) * 1.5, 0.03) if mag.size else 0.1
323
+ ax.set_ylim(-A, A)
324
+ ax.set_ylabel("Score")
325
+ ax.margins(x=0)
326
+ _overlay_boundaries(ax, d["pred_secs"], [], show_truth=False)
327
+ ax.legend(loc="upper right", fontsize=7, framealpha=0.9, ncol=2)
328
+
329
+
330
+ # Order shared by both makers: (key, caption, drawer, separate-figure height).
331
+ _PANELS = [
332
+ ("waveform", "1. Waveform", _panel_waveform, 2.6),
333
+ ("spectrogram", "2. Log-mel spectrogram", _panel_spectrogram, 2.8),
334
+ ("posteriors", "3. Phoneme posteriors", _panel_posteriors, 3.6),
335
+ ("dp", "4. Soft-DP cost matrix + alignment path", _panel_dp, 3.4),
336
+ ("contrastive", "5. Contrastive boundary score", _panel_contrastive, 2.6),
337
+ ]
338
+
339
+
340
+ # --------------------------------------------------------------------------- #
341
+ # Combined figure (README) #
342
+ # --------------------------------------------------------------------------- #
343
+ def make_alignment_panels(wav, ckpt, out_path, w_phi=0.5, language="english",
344
+ annotation="phn", show_truth=True):
345
+ """Build the stacked, time-aligned multi-panel figure and save it to out_path."""
346
+ if language != "english":
347
+ print(f"[falcon_viz] language='{language}' not supported; using english path.")
348
+ d = _extract_arrays(wav, ckpt, annotation)
349
+ dur = d["duration"]
350
+
351
+ fig = plt.figure(figsize=(12, 17), dpi=150, constrained_layout=True)
352
+ gs = GridSpec(5, 1, figure=fig, height_ratios=[1.0, 1.3, 1.7, 1.6, 1.0])
353
+ ax_wave = fig.add_subplot(gs[0])
354
+ ax_spec = fig.add_subplot(gs[1], sharex=ax_wave)
355
+ ax_post = fig.add_subplot(gs[2], sharex=ax_wave)
356
+ ax_dp = fig.add_subplot(gs[3], sharex=ax_wave)
357
+ ax_score = fig.add_subplot(gs[4], sharex=ax_wave)
358
+ axes = [ax_wave, ax_spec, ax_post, ax_dp, ax_score]
359
+
360
+ _panel_waveform(ax_wave, d, show_truth, label_first=True)
361
+ _panel_spectrogram(ax_spec, d, show_truth)
362
+ _panel_posteriors(ax_post, d, show_truth, colorbar=True)
363
+ _panel_dp(ax_dp, d, show_truth, colorbar=True)
364
+ _panel_contrastive(ax_score, d, show_truth)
365
+
366
+ for ax, (_key, caption, _drawer, _h) in zip(axes, _PANELS):
367
+ ax.set_title(caption, loc="left", fontweight="bold", fontsize=11)
368
+ ax.tick_params(labelbottom=True)
369
+ _annotate_phoneme_tier(ax, d)
370
+ ax_score.set_xlabel("Time (s)", fontsize=12)
371
+ ax_wave.set_xlim(0, dur)
372
+
373
+ fig.suptitle("FALCON forced-alignment — time-aligned representations",
374
+ fontsize=14, fontweight="bold")
375
+ fig.savefig(out_path, dpi=150, bbox_inches="tight")
376
+ plt.close(fig)
377
+ print(f"[falcon_viz] saved {out_path}")
378
+ return out_path
379
+
380
+
381
+ # --------------------------------------------------------------------------- #
382
+ # Separate per-panel figures (web app) #
383
+ # --------------------------------------------------------------------------- #
384
+ def make_alignment_separate(wav, ckpt, out_dir, w_phi=0.5, language="english",
385
+ annotation="phn", show_truth=True):
386
+ """Build one wide, full-size figure per panel; return [(path, caption), ...]."""
387
+ if language != "english":
388
+ print(f"[falcon_viz] language='{language}' not supported; using english path.")
389
+ d = _extract_arrays(wav, ckpt, annotation)
390
+ dur = d["duration"]
391
+ os.makedirs(out_dir, exist_ok=True)
392
+
393
+ out = []
394
+ for key, caption, drawer, h in _PANELS:
395
+ fig, ax = plt.subplots(figsize=(12, h), dpi=130)
396
+ if key == "waveform":
397
+ drawer(ax, d, show_truth, label_first=True)
398
+ else:
399
+ drawer(ax, d, show_truth)
400
+ ax.set_title(caption, loc="left", fontweight="bold", fontsize=12)
401
+ ax.set_xlim(0, dur)
402
+ _annotate_phoneme_tier(ax, d)
403
+ ax.set_xlabel("Time (s)", labelpad=26)
404
+ p = os.path.join(out_dir, f"panel_{key}.png")
405
+ fig.savefig(p, dpi=130, bbox_inches="tight")
406
+ plt.close(fig)
407
+ out.append((p, caption))
408
+ return out
409
+
410
+
411
+ if __name__ == "__main__":
412
+ _here = os.path.dirname(os.path.abspath(__file__))
413
+ make_alignment_panels(
414
+ wav=os.path.join(_here, "assets", "fasw0sa2.wav"),
415
+ ckpt=os.path.join(_here, "pretrained_models", "falcon_timit_english.pt"),
416
+ out_path=os.path.join(_here, "assets", "example_panels.png"),
417
+ )
mfa_assets/extracted_models/g2p/english_us_arpa_g2p/model.fst ADDED

Git LFS Details

  • SHA256: b346a7fe5c6e42c4e7632ea2c0c5f3ad69cb2e637784830c4ba51725d8fc0fda
  • Pointer size: 133 Bytes
  • Size of remote file: 30 MB
mfa_assets/extracted_models/g2p/english_us_arpa_g2p/phones.sym ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <eps> 0
2
+ sil 1
3
+ spn 2
4
+ AA0 3
5
+ AA1 4
6
+ AA2 5
7
+ AE0 6
8
+ AE1 7
9
+ AE2 8
10
+ AH0 9
11
+ AH1 10
12
+ AH2 11
13
+ AO0 12
14
+ AO1 13
15
+ AO2 14
16
+ AW0 15
17
+ AW1 16
18
+ AW2 17
19
+ AY0 18
20
+ AY1 19
21
+ AY2 20
22
+ B 21
23
+ CH 22
24
+ D 23
25
+ DH 24
26
+ EH0 25
27
+ EH1 26
28
+ EH2 27
29
+ ER0 28
30
+ ER1 29
31
+ ER2 30
32
+ EY0 31
33
+ EY1 32
34
+ EY2 33
35
+ F 34
36
+ G 35
37
+ HH 36
38
+ IH0 37
39
+ IH1 38
40
+ IH2 39
41
+ IY0 40
42
+ IY1 41
43
+ IY2 42
44
+ JH 43
45
+ K 44
46
+ L 45
47
+ M 46
48
+ N 47
49
+ NG 48
50
+ OW0 49
51
+ OW1 50
52
+ OW2 51
53
+ OY0 52
54
+ OY1 53
55
+ OY2 54
56
+ P 55
57
+ R 56
58
+ S 57
59
+ SH 58
60
+ T 59
61
+ TH 60
62
+ UH0 61
63
+ UH1 62
64
+ UH2 63
65
+ UW0 64
66
+ UW1 65
67
+ UW2 66
68
+ V 67
69
+ W 68
70
+ Y 69
71
+ Z 70
72
+ ZH 71
73
+ #0 72
74
+ #1 73
75
+ #2 74
76
+ #3 75
77
+ #4 76
78
+ #5 77
79
+ #6 78
80
+ #7 79
81
+ #8 80
82
+ #9 81
83
+ #10 82
84
+ #11 83
85
+ #12 84
86
+ #13 85
87
+ #14 86
88
+ #15 87
89
+ #16 88
mfa_assets/pretrained_models/dictionary/dutch_cv.dict ADDED
The diff for this file is too large to render. See raw diff
 
mfa_assets/pretrained_models/dictionary/english_us_arpa.dict ADDED
The diff for this file is too large to render. See raw diff
 
mfa_assets/pretrained_models/dictionary/german_mfa.dict ADDED
The diff for this file is too large to render. See raw diff
 
mfa_g2p.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MFA-compatible word -> Lee-Hon-39 phoneme front-end for *word-level* alignment.
3
+
4
+ This is the apples-to-apples counterpart of `word_g2p.py` (which uses espeak):
5
+ instead of espeak it phonemizes orthographic words with the **same** open-source
6
+ G2P models that Montreal Forced Aligner uses, then maps the result into LH39:
7
+
8
+ word --MFA pronunciation dictionary lookup (highest-prob entry)--> phones
9
+ word (OOV, English only) --english_us_arpa pynini G2P WFST--> ARPAbet
10
+ English ARPAbet --strip stress, lowercase, timit_to_leehon_map_MACRO--> LH39
11
+ Dutch/German IPA --panphon articulatory distance (dutch_preprocess)--> LH39
12
+
13
+ MFA at alignment time looks each word up in its dictionary first and only invokes
14
+ the G2P WFST for out-of-vocabulary words; this module mirrors that. Selecting this
15
+ backend (vs espeak) isolates the G2P front-end as the only thing that differs from
16
+ MFA, so a word-level FDNFA-vs-MFA comparison measures the *aligner*, not the
17
+ phonemizer.
18
+
19
+ Language is chosen with FDNFA_G2P_VOICE (the same env word_g2p uses):
20
+ en-us -> english_us_arpa (ARPAbet, pynini OOV) nl -> dutch_cv (IPA)
21
+ de -> german_mfa (IPA) he -> no MFA model (espeak fallback)
22
+ """
23
+ import os
24
+ import subprocess
25
+
26
+ from utils import timit_to_leehon_map_MACRO, timit_leehon_39_phonemes
27
+
28
+ # Default MFA model locations (the standard `mfa model download` cache).
29
+ _MFA_ROOT = os.environ.get("MFA_ROOT_DIR", os.path.expanduser("~/Documents/MFA"))
30
+ _DICT_DIR = os.path.join(_MFA_ROOT, "pretrained_models", "dictionary")
31
+ _ARPA_G2P_DIR = os.path.join(_MFA_ROOT, "extracted_models", "g2p", "english_us_arpa_g2p")
32
+ ARPA_G2P_FST = os.environ.get("FDNFA_MFA_G2P_FST", os.path.join(_ARPA_G2P_DIR, "model.fst"))
33
+ ARPA_G2P_PHONES = os.environ.get("FDNFA_MFA_G2P_PHONES", os.path.join(_ARPA_G2P_DIR, "phones.sym"))
34
+ # conda env that has pynini installed (for OOV G2P on the WFST).
35
+ MFA_ENV_PY = os.environ.get("FDNFA_MFA_ENV_PY", os.path.expanduser("~/miniconda3/envs/aligner/bin/python"))
36
+
37
+ # voice -> (dictionary file, phone alphabet). FDNFA_MFA_DICT overrides the dict.
38
+ _LANG_CFG = {
39
+ "en-us": ("english_us_arpa.dict", "arpa"),
40
+ "en": ("english_us_arpa.dict", "arpa"),
41
+ "nl": ("dutch_cv.dict", "ipa"),
42
+ "de": ("german_mfa.dict", "ipa"),
43
+ }
44
+ _VOICE = os.environ.get("FDNFA_G2P_VOICE", "en-us")
45
+
46
+
47
+ def _cfg_for_voice(voice):
48
+ """(dictionary path, phone alphabet) for an espeak-style voice code.
49
+ FDNFA_MFA_DICT overrides the dictionary path for the default voice only."""
50
+ voice = voice or _VOICE
51
+ dict_file, alphabet = _LANG_CFG.get(voice, ("english_us_arpa.dict", "arpa"))
52
+ if voice == _VOICE and os.environ.get("FDNFA_MFA_DICT"):
53
+ return os.environ["FDNFA_MFA_DICT"], alphabet
54
+ return os.path.join(_DICT_DIR, dict_file), alphabet
55
+
56
+
57
+ def mfa_available(voice=None):
58
+ """True if an MFA pronunciation dictionary exists locally for this language.
59
+ Callers (e.g. the app) use this to decide whether to use the MFA-like G2P or
60
+ fall back to espeak when the dictionary isn't installed."""
61
+ dict_path, _ = _cfg_for_voice(voice)
62
+ return os.path.exists(dict_path)
63
+
64
+
65
+ # Backwards-compatible module-level defaults (the default voice's config).
66
+ _DICT_FILE, _ALPHABET = _LANG_CFG.get(_VOICE, ("english_us_arpa.dict", "arpa"))
67
+ ARPA_DICT, _ = _cfg_for_voice(_VOICE)
68
+
69
+ # Reuse the exact closure-insertion rule from the espeak front-end so the only
70
+ # thing differing between the espeak and MFA word backends is the G2P.
71
+ from word_g2p import USE_CLOSURES, _with_closures
72
+
73
+ _dicts = {} # voice -> {word_lower: [phones]} (highest-prob entry)
74
+ _cache = {} # (word_lower, voice) -> [lh39, ...]
75
+ _oov = set() # words not found in any dictionary (for reporting)
76
+
77
+
78
+ def _load_dict(voice=None):
79
+ """Parse the MFA dictionary for `voice` once (cached per voice). Format:
80
+ word <tab> [prob cols <tab>] PHONES, where PHONES (final tab-separated field)
81
+ is space-separated phones and the first float column (when present) is the
82
+ pronunciation probability.
83
+
84
+ Like MFA, keep the **highest-probability** pronunciation per word (MFA's most-
85
+ likely variant; it then disambiguates acoustically, which we cannot). Entries
86
+ with no probability column are treated as probability 1.0."""
87
+ voice = voice or _VOICE
88
+ if voice in _dicts:
89
+ return _dicts[voice]
90
+ dict_path, _alpha = _cfg_for_voice(voice)
91
+ best = {} # word -> (prob, phones)
92
+ with open(dict_path, "r", encoding="utf-8") as f:
93
+ for line in f:
94
+ line = line.rstrip("\n")
95
+ if not line:
96
+ continue
97
+ parts = line.split("\t")
98
+ if len(parts) < 2:
99
+ continue
100
+ word = parts[0].lower()
101
+ phones = parts[-1].split()
102
+ try:
103
+ prob = float(parts[1]) if len(parts) >= 3 else 1.0
104
+ except ValueError:
105
+ prob = 1.0
106
+ if word and (word not in best or prob > best[word][0]):
107
+ best[word] = (prob, phones)
108
+ _dicts[voice] = {w: ph for w, (_, ph) in best.items()}
109
+ return _dicts[voice]
110
+
111
+
112
+ def arpa_to_lh39(phones):
113
+ """ARPAbet (with stress digits) -> LH39, dropping non-phone tokens (spn/sil)."""
114
+ out = []
115
+ for p in phones:
116
+ base = p.rstrip("0123456789").lower() # AH0 -> ah, B -> b
117
+ if base in ("spn", "sil", "sp", ""):
118
+ continue
119
+ if base in timit_leehon_39_phonemes:
120
+ out.append(base)
121
+ else:
122
+ out.append(timit_to_leehon_map_MACRO.get(base, "sil"))
123
+ return out
124
+
125
+
126
+ def ipa_to_lh39(phones):
127
+ """IPA phones (dutch_cv / german_mfa dicts) -> LH39 via panphon distance — the
128
+ same articulatory mapping the espeak/phoneme paths use (dutch_preprocess)."""
129
+ import dutch_preprocess
130
+ out = []
131
+ for p in phones:
132
+ if p in ("spn", "sil", "sp", ""):
133
+ continue
134
+ out.append(dutch_preprocess.find_best_leehon39(p)[0])
135
+ return out
136
+
137
+
138
+ def _g2p_oov(word):
139
+ """Phonemize an OOV English word with the english_us_arpa pynini WFST (run in
140
+ the mfa env, which has pynini). Mirrors MFA's own G2P: compose the word
141
+ acceptor with the pair-n-gram model and take the shortest path, decoded via
142
+ phones.sym. Returns a list of ARPAbet phones, or [] if unavailable."""
143
+ if not (os.path.exists(MFA_ENV_PY) and os.path.exists(ARPA_G2P_FST)
144
+ and os.path.exists(ARPA_G2P_PHONES)):
145
+ return []
146
+ code = (
147
+ "import sys,pynini\n"
148
+ f"fst=pynini.Fst.read({ARPA_G2P_FST!r})\n"
149
+ f"ps=pynini.SymbolTable.read_text({ARPA_G2P_PHONES!r})\n"
150
+ "fst.set_output_symbols(ps)\n"
151
+ "w=sys.argv[1].lower()\n"
152
+ "try:\n"
153
+ " lat=pynini.compose(pynini.accep(w, token_type='utf8'), fst)\n"
154
+ " print(pynini.shortestpath(lat).string(ps))\n"
155
+ "except Exception:\n"
156
+ " print('')\n"
157
+ )
158
+ try:
159
+ out = subprocess.run([MFA_ENV_PY, "-c", code, word],
160
+ capture_output=True, text=True, timeout=30).stdout
161
+ return out.strip().split()
162
+ except Exception:
163
+ return []
164
+
165
+
166
+ def word_to_lh39_mfa(word, voice=None):
167
+ """Orthographic word -> list of LH39 phonemes via the MFA G2P for `voice`
168
+ (en-us/en -> english_us_arpa + pynini OOV; de -> german_mfa; nl -> dutch_cv).
169
+ `voice=None` uses the env default (FDNFA_G2P_VOICE), preserving the original
170
+ single-language behaviour."""
171
+ voice = voice or _VOICE
172
+ _dict_path, alphabet = _cfg_for_voice(voice)
173
+ key = (word.lower(), voice)
174
+ if key in _cache:
175
+ return _cache[key]
176
+ d = _load_dict(voice)
177
+ phones = d.get(word.lower())
178
+ if phones is None:
179
+ _oov.add(word.lower())
180
+ # English OOV -> MFA's pynini WFST. German OOV is pre-resolved in the
181
+ # merged dictionary. Any word still unresolved (e.g. Dutch, which has no
182
+ # MFA G2P model) becomes a single 'sil', exactly as MFA treats an unknown
183
+ # word.
184
+ if alphabet == "arpa":
185
+ phones = _g2p_oov(word.lower())
186
+ if alphabet == "arpa":
187
+ lh39 = arpa_to_lh39(phones) if phones else []
188
+ else:
189
+ lh39 = ipa_to_lh39(phones) if phones else []
190
+ if not lh39:
191
+ lh39 = ["sil"]
192
+ if USE_CLOSURES:
193
+ lh39 = _with_closures(lh39)
194
+ _cache[key] = lh39
195
+ return lh39
196
+
197
+
198
+ def oov_words():
199
+ return set(_oov)
next_frame_classifier.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+ import hydra
6
+ from utils import LambdaLayer, PrintShapeLayer, length_to_mask, get_timit_61_phoneme_mappings, timit_to_leehon
7
+ from dataloader import TrainTestDataset
8
+ from collections import defaultdict
9
+ import random
10
+ from utils import max_min_norm, detect_peaks, create_truth_probs_real
11
+ import torch.nn.utils.rnn as rnn_utils
12
+ from memory_profiler import profile
13
+
14
+ class NextFrameClassifier(nn.Module):
15
+ def __init__(self, hp):
16
+ super(NextFrameClassifier, self).__init__()
17
+ self.w_phi = nn.Parameter(torch.tensor([0.5, 0.5], dtype=torch.float32))
18
+ self.w_pos_neg = nn.Parameter(torch.tensor([0.5, 0.5], dtype=torch.float32))
19
+ self.hp = hp
20
+
21
+ Z_DIM = hp.z_dim
22
+ LS = hp.latent_dim if hp.latent_dim != 0 else Z_DIM
23
+
24
+ self.phoneme_to_index, self.idx_to_phoneme = get_timit_61_phoneme_mappings()
25
+
26
+ self.enc = nn.Sequential(
27
+ nn.Conv1d(1, LS, kernel_size=10, stride=5, padding=0, bias=False),
28
+ nn.BatchNorm1d(LS),
29
+ nn.LeakyReLU(),
30
+ nn.Conv1d(LS, LS, kernel_size=8, stride=4, padding=0, bias=False),
31
+ nn.BatchNorm1d(LS),
32
+ nn.LeakyReLU(),
33
+ nn.Conv1d(LS, LS, kernel_size=4, stride=2, padding=0, bias=False),
34
+ nn.BatchNorm1d(LS),
35
+ nn.LeakyReLU(),
36
+ nn.Conv1d(LS, LS, kernel_size=4, stride=2, padding=0, bias=False),
37
+ nn.BatchNorm1d(LS),
38
+ nn.LeakyReLU(),
39
+ nn.Conv1d(LS, Z_DIM, kernel_size=4, stride=2, padding=0, bias=False),
40
+ LambdaLayer(lambda x: x.transpose(1,2)),
41
+ )
42
+ print("learning features from raw wav")
43
+
44
+ if self.hp.z_proj != 0:
45
+ if self.hp.z_proj_linear:
46
+ self.enc.add_module(
47
+ "z_proj",
48
+ nn.Sequential(
49
+ nn.Dropout1d(self.hp.z_proj_dropout),
50
+ nn.Linear(Z_DIM, self.hp.z_proj),
51
+ )
52
+ )
53
+ else:
54
+ self.enc.add_module(
55
+ "z_proj",
56
+ nn.Sequential(
57
+ nn.Dropout1d(self.hp.z_proj_dropout),
58
+ nn.Linear(Z_DIM, Z_DIM), nn.LeakyReLU(),
59
+ nn.Dropout1d(self.hp.z_proj_dropout),
60
+ nn.Linear(Z_DIM, self.hp.z_proj),
61
+ )
62
+ )
63
+ self.pred_steps = list(range(1 + self.hp.pred_offset, 1 + self.hp.pred_offset + self.hp.pred_steps))
64
+ print(f"prediction steps: {self.pred_steps}")
65
+
66
+ self.bi_lstm = nn.LSTM(
67
+ input_size=self.hp.z_proj,
68
+ hidden_size=512,
69
+ num_layers=5,#3,
70
+ bidirectional=True,
71
+ batch_first=True
72
+ )
73
+ def init_weights(m):
74
+ if isinstance(m, nn.LSTM):
75
+ for name, param in m.named_parameters():
76
+ if 'weight' in name:
77
+ nn.init.xavier_uniform_(param.data)
78
+ elif 'bias' in name:
79
+ nn.init.zeros_(param.data)
80
+ self.bi_lstm.apply(init_weights)
81
+ self.fc = nn.Linear(512 * 2, hp.num_classes)
82
+
83
+ def score(self, f, b):
84
+ return F.cosine_similarity(f, b, dim=-1) * self.hp.cosine_coef
85
+ # @profile
86
+ def forward(self, spect, seg, phonemes, length):
87
+ device = next(self.parameters()).device
88
+ spect = spect.to(device)
89
+ if length is not None and isinstance(length, torch.Tensor):
90
+ length = length.to(device)
91
+ z = self.enc(spect.unsqueeze(1))
92
+
93
+ del spect
94
+ torch.cuda.empty_cache()
95
+
96
+ z = F.normalize(z, dim=-1)
97
+
98
+ z_bilstm, _ = self.bi_lstm(z)
99
+ logits = self.fc(z_bilstm)
100
+ probs = F.softmax(logits, dim=-1)
101
+
102
+ probs = logits
103
+ frame_labels = torch.zeros_like(logits, device=device)
104
+
105
+ preds = defaultdict(list)
106
+ for i, t in enumerate(self.pred_steps):
107
+ positive_b_scores_list = []
108
+ negative_b_scores_list = []
109
+ if seg is None:
110
+ z_seg = z
111
+ score = self.score(z_seg[:, :-t], z_seg[:, t:])
112
+ for b in range(z.shape[0]):
113
+ positive_b_scores_list.append(score[b])
114
+ negative_b_scores_list.append(score[b])
115
+ else:
116
+ for b in range(z.shape[0]):
117
+ seg_b = [0] + seg[b][:] + [z[b].shape[0]]
118
+ pos_scores = []
119
+ neg_scores = []
120
+ segment_pairs = list(zip(seg_b[:-1], seg_b[1:-1]))
121
+ num_repeats = 5
122
+ for ki, kii in segment_pairs:
123
+ mid = ki + (kii - ki) // 2
124
+ sample_len = 1
125
+ start_idx = int(ki + 0.2 * (kii - ki))
126
+ end_idx = int(ki + 0.8 * (kii - ki))
127
+ num_idx = end_idx - start_idx
128
+ # Batch positive sampling
129
+ if num_idx >= sample_len:
130
+ idxs = torch.tensor(
131
+ random.choices(range(start_idx, end_idx), k=num_repeats),
132
+ device=device
133
+ )
134
+ z_pos = z[b, idxs]
135
+ else:
136
+ idx_start = int(mid - (sample_len // 2))
137
+ idx_end = int(mid + (sample_len // 2))
138
+ z_pos = z[b, idx_start:idx_end].repeat(num_repeats, 1)
139
+ pos_scores.append(self.score(z_pos[:, :-t], z_pos[:, t:]))
140
+ # Batch negative sampling
141
+ if ki - (sample_len // 2) <= 0 or kii + (sample_len // 2) >= z[b].shape[0]:
142
+ idx_start = int(mid - (sample_len // 2))
143
+ idx_end = int(mid + (sample_len // 2))
144
+ z_neg = z[b, idx_start:idx_end].repeat(num_repeats, 1)
145
+ else:
146
+ idx_start = int(kii - (sample_len // 2))
147
+ idx_end = int(kii + (sample_len // 2))
148
+ z_neg = z[b, idx_start:idx_end].repeat(num_repeats, 1)
149
+ neg_scores.append(self.score(z_neg[:, :-t], z_neg[:, t:]))
150
+ if pos_scores:
151
+ positive_b_scores_list.append(torch.cat(pos_scores))
152
+ negative_b_scores_list.append(torch.cat(neg_scores))
153
+ # Padding and stacking
154
+ if positive_b_scores_list:
155
+ original_lengths = torch.tensor([x.shape[0] for x in positive_b_scores_list], dtype=torch.int64, device=device)
156
+ max_length = original_lengths.max().item()
157
+ def pad_scores(scores):
158
+ padded = torch.full((max_length,), float(0), device=device)
159
+ padded[:scores.shape[0]] = scores
160
+ return padded
161
+ positive_b_scores = torch.stack([pad_scores(x) for x in positive_b_scores_list])
162
+ negative_b_scores = torch.stack([pad_scores(x) for x in negative_b_scores_list])
163
+ else:
164
+ batch_size = z.shape[0]
165
+ max_length = 1
166
+ positive_b_scores = torch.full((batch_size, max_length), float(0), device=device)
167
+ negative_b_scores = torch.full((batch_size, max_length), float(0), device=device)
168
+ original_lengths = torch.ones(batch_size, dtype=torch.int64, device=device)
169
+ preds[t].append(positive_b_scores)
170
+ preds[t].append(negative_b_scores)
171
+
172
+ # Peak detection and post-processing (not differentiable, but kept for completeness)
173
+ total_peaks = []
174
+ for i in range(len(phonemes)):
175
+ if seg is not None:
176
+ segments = seg[i]
177
+ else:
178
+ segments = []
179
+
180
+ probs_real = F.softmax(probs[i], dim=-1).squeeze(0)
181
+
182
+ cur_preds = preds[1][0][i]
183
+ cur_preds = max_min_norm(cur_preds)
184
+
185
+ median_h = cur_preds.median()
186
+ preds_np = cur_preds - median_h
187
+ w_phi = torch.softmax(self.w_phi, dim=0)
188
+
189
+
190
+ sr = 16000
191
+ len_ratio = 161.34011627906978
192
+ preds_peaks = detect_peaks(
193
+ x= (cur_preds), #(-1 * cur_preds),
194
+ w_phi=w_phi,
195
+ original_lengths_all=original_lengths[i],
196
+ phonemes=phonemes[i],
197
+ len_ratio=len_ratio,
198
+ probs_real_all=probs_real
199
+ )
200
+ preds_peaks = preds_peaks[0] * len_ratio / sr
201
+ if seg is not None:
202
+ segments = np.array(segments) * len_ratio / sr
203
+
204
+ # print("truth boundaries ('segments'):")
205
+ # print(segments)
206
+ # print("predicted boundaries (in seconds):")
207
+ # print(preds_peaks)
208
+
209
+ total_peaks.append(preds_peaks)
210
+
211
+ # for debug mode plotting only ---> probs=z
212
+ return preds, original_lengths, probs, frame_labels, seg, total_peaks, w_phi
213
+
214
+
215
+ def loss_ph(self, preds, original_lengths, probs, frame_labels, seg, total_peaks, w_phi, phonemes):
216
+ if seg is not None:
217
+ for i, (segments, phs) in enumerate(zip(seg, phonemes)):
218
+ starts = np.array([0] + list(segments[:-1]), dtype=int)
219
+ ends = np.array(segments[:], dtype=int)
220
+ labels = [timit_to_leehon(p) or 'sil' for p in phs]
221
+ label_indices = [self.phoneme_to_index[l] for l in labels]
222
+ for start, end, label_index in zip(starts, ends, label_indices):
223
+ frame_labels[i, start:end, label_index] = 1.0
224
+ total_loss = 0.0
225
+ probs = probs.view(-1, probs.size(-1))
226
+ frame_labels = frame_labels.view(-1, frame_labels.size(-1))
227
+ ph_loss = F.cross_entropy(probs, frame_labels.argmax(dim=-1))
228
+ loss = 0
229
+
230
+ for t, t_preds in preds.items():
231
+ mask = length_to_mask(original_lengths - t + 1)
232
+ out = torch.stack(t_preds, dim=-1)
233
+ out = F.log_softmax(out, dim=-1)
234
+ pos_loss = out[..., 0] * mask
235
+ neg_loss = out[..., 1] * mask
236
+
237
+ pos_count = mask.sum()
238
+ neg_count = mask.sum()
239
+
240
+ pos_loss_mean = pos_loss.sum() / pos_count if pos_count > 0 else torch.tensor(0.0, device=pos_loss.device)
241
+ neg_loss_mean = neg_loss.sum() / neg_count if neg_count > 0 else torch.tensor(0.0, device=neg_loss.device)
242
+
243
+ w_pos_neg = torch.softmax(self.w_pos_neg, dim=0)
244
+
245
+ l_pos_neg = torch.stack([pos_loss_mean, -neg_loss_mean])
246
+ loss += -torch.dot(w_pos_neg, l_pos_neg)
247
+
248
+ total_loss = (loss) + 0.2*ph_loss
249
+
250
+ sum_mse = torch.tensor(0.0, device=probs.device)
251
+
252
+ if seg is not None and len(total_peaks) > 0:
253
+ seg_tensors = [torch.tensor(s, dtype=torch.float32, device=probs.device) for s in seg]
254
+ for i in range(len(seg_tensors)):
255
+ seg_tensors[i] = seg_tensors[i]*161.34011627906978/16000 # convert to seconds
256
+ peaks_tensors = [torch.tensor(tp[1:], dtype=torch.float32, device=probs.device) for tp in total_peaks]
257
+
258
+ mse = 0.0
259
+ for i in range(len(seg_tensors)):
260
+ mse = mse + F.mse_loss(seg_tensors[i], peaks_tensors[i])
261
+ mse = mse / len(seg_tensors)
262
+
263
+ sum_mse = mse
264
+
265
+ total_loss = ph_loss
266
+
267
+ return total_loss, ph_loss, loss, sum_mse, w_pos_neg, w_phi
268
+
269
+ def loss_nce(self, preds, original_lengths, probs, frame_labels, seg, total_peaks, w_phi, phonemes):
270
+ if seg is not None:
271
+ for i, (segments, phs) in enumerate(zip(seg, phonemes)):
272
+ starts = np.array([0] + list(segments[:-1]), dtype=int)
273
+ ends = np.array(segments[:], dtype=int)
274
+ labels = [timit_to_leehon(p) or 'sil' for p in phs]
275
+ label_indices = [self.phoneme_to_index[l] for l in labels]
276
+ for start, end, label_index in zip(starts, ends, label_indices):
277
+ frame_labels[i, start:end, label_index] = 1.0
278
+
279
+ total_loss = 0.0
280
+ probs = probs.view(-1, probs.size(-1))
281
+ frame_labels = frame_labels.view(-1, frame_labels.size(-1))
282
+ ph_loss = F.cross_entropy(probs, frame_labels.argmax(dim=-1))
283
+ loss = 0
284
+
285
+ for t, t_preds in preds.items():
286
+ mask = length_to_mask(original_lengths - t + 1)
287
+ out = torch.stack(t_preds, dim=-1)
288
+ out = F.log_softmax(out, dim=-1)
289
+ pos_loss = out[..., 0] * mask
290
+ neg_loss = out[..., 1] * mask
291
+
292
+ pos_count = mask.sum()
293
+ neg_count = mask.sum()
294
+
295
+ pos_loss_mean = pos_loss.sum() / pos_count if pos_count > 0 else torch.tensor(0.0, device=pos_loss.device)
296
+ neg_loss_mean = neg_loss.sum() / neg_count if neg_count > 0 else torch.tensor(0.0, device=neg_loss.device)
297
+
298
+ w_pos_neg = torch.softmax(self.w_pos_neg, dim=0)
299
+
300
+ l_pos_neg = torch.stack([pos_loss_mean, -neg_loss_mean])
301
+ loss += -torch.dot(w_pos_neg, l_pos_neg)
302
+
303
+ total_loss = (loss)
304
+
305
+ sum_mse = torch.tensor(0.0, device=probs.device)
306
+
307
+ if seg is not None and len(total_peaks) > 0:
308
+ seg_tensors = [torch.tensor(s, dtype=torch.float32, device=probs.device) for s in seg]
309
+ for i in range(len(seg_tensors)):
310
+ seg_tensors[i] = seg_tensors[i]*161.34011627906978/16000 # convert to seconds
311
+ peaks_tensors = [torch.tensor(tp[1:], dtype=torch.float32, device=probs.device) for tp in total_peaks]
312
+
313
+ mse = 0.0
314
+ for i in range(len(seg_tensors)):
315
+ mse = mse + F.mse_loss(seg_tensors[i], peaks_tensors[i])
316
+ mse = mse / len(seg_tensors)
317
+
318
+ sum_mse = mse
319
+ total_loss = loss
320
+
321
+ return total_loss, ph_loss, loss, sum_mse, w_pos_neg, w_phi
322
+
323
+ # ------------------------------ For Ablations ---------------------------------
324
+ def loss_InfoNCE_classic(self, preds, original_lengths, probs, frame_labels, seg, total_peaks, w_phi, phonemes):
325
+ """
326
+ Classic InfoNCE implementation.
327
+ Standard log-softmax over one positive and N negatives.
328
+ """
329
+ device = probs.device
330
+ total_loss = 0.0
331
+ probs_flat = probs.view(-1, probs.size(-1))
332
+ frame_labels_flat = frame_labels.view(-1, frame_labels.size(-1))
333
+ ph_loss = F.cross_entropy(probs_flat, frame_labels_flat.argmax(dim=-1))
334
+
335
+ nce_loss_accum = 0.0
336
+
337
+ for t, t_preds in preds.items():
338
+ logits = torch.stack(t_preds, dim=-1)
339
+ batch_size, seq_len, _ = logits.shape
340
+ target = torch.zeros((batch_size, seq_len), dtype=torch.long, device=device)
341
+ mask = length_to_mask(original_lengths - t + 1)
342
+ logits_flat = logits.view(-1, 2)
343
+ target_flat = target.view(-1)
344
+ loss_fn = nn.CrossEntropyLoss(reduction='none')
345
+ raw_loss = loss_fn(logits_flat, target_flat)
346
+ masked_loss = raw_loss * mask.view(-1)
347
+ nce_loss_accum += masked_loss.sum() / mask.sum()
348
+ total_loss = nce_loss_accum / len(preds)
349
+ sum_mse = torch.tensor(0.0, device=device)
350
+ w_pos_neg = torch.softmax(self.w_pos_neg, dim=0)
351
+
352
+ return total_loss, ph_loss, nce_loss_accum, sum_mse, w_pos_neg, w_phi
353
+ # -----------------------------------------------------------------------------
354
+
355
+ def loss_mse(self, preds, original_lengths, probs, frame_labels, seg, total_peaks, w_phi, phonemes):
356
+ if seg is not None:
357
+ for i, (segments, phs) in enumerate(zip(seg, phonemes)):
358
+ starts = np.array([0] + list(segments[:-1]), dtype=int)
359
+ ends = np.array(segments[:], dtype=int)
360
+ labels = [timit_to_leehon(p) or 'sil' for p in phs]
361
+ label_indices = [self.phoneme_to_index[l] for l in labels]
362
+ for start, end, label_index in zip(starts, ends, label_indices):
363
+ frame_labels[i, start:end, label_index] = 1.0
364
+
365
+ total_loss = 0.0
366
+ probs = probs.view(-1, probs.size(-1))
367
+ frame_labels = frame_labels.view(-1, frame_labels.size(-1))
368
+ ph_loss = F.cross_entropy(probs, frame_labels.argmax(dim=-1))
369
+ loss = 0
370
+
371
+ for t, t_preds in preds.items():
372
+ mask = length_to_mask(original_lengths - t + 1)
373
+ out = torch.stack(t_preds, dim=-1)
374
+ out = F.log_softmax(out, dim=-1)
375
+ pos_loss = out[..., 0] * mask
376
+ neg_loss = out[..., 1] * mask
377
+
378
+ pos_count = mask.sum()
379
+ neg_count = mask.sum()
380
+
381
+ pos_loss_mean = pos_loss.sum() / pos_count if pos_count > 0 else torch.tensor(0.0, device=pos_loss.device)
382
+ neg_loss_mean = neg_loss.sum() / neg_count if neg_count > 0 else torch.tensor(0.0, device=neg_loss.device)
383
+
384
+ w_pos_neg = torch.softmax(self.w_pos_neg, dim=0)
385
+
386
+ l_pos_neg = torch.stack([pos_loss_mean, -neg_loss_mean])
387
+ loss += -torch.dot(w_pos_neg, l_pos_neg)
388
+
389
+ total_loss = (loss) + 0.2*ph_loss
390
+ sum_mse = torch.tensor(0.0, device=probs.device)
391
+
392
+ if seg is not None and len(total_peaks) > 0:
393
+ seg_tensors = [torch.tensor(s, dtype=torch.float32, device=probs.device) for s in seg]
394
+ for i in range(len(seg_tensors)):
395
+ seg_tensors[i] = seg_tensors[i]*161.34011627906978/16000 # convert to seconds
396
+ peaks_tensors = [torch.tensor(tp[1:], dtype=torch.float32, device=probs.device) for tp in total_peaks]
397
+ mse = 0.0
398
+ for i in range(len(seg_tensors)):
399
+ mse = mse + F.mse_loss(seg_tensors[i], peaks_tensors[i])
400
+ mse = mse / len(seg_tensors)
401
+ sum_mse = mse
402
+ total_loss = sum_mse
403
+
404
+ return total_loss, ph_loss, loss, sum_mse, w_pos_neg, w_phi
405
+
406
+ def total_loss(self, preds, original_lengths, probs, frame_labels, seg, total_peaks, w_phi,phonemes):
407
+ if seg is not None:
408
+ for i, (segments, phs) in enumerate(zip(seg, phonemes)):
409
+ starts = np.array([0] + list(segments[:-1]), dtype=int)
410
+ ends = np.array(segments[:], dtype=int)
411
+ labels = [timit_to_leehon(p) or 'sil' for p in phs]
412
+ label_indices = [self.phoneme_to_index[l] for l in labels]
413
+ for start, end, label_index in zip(starts, ends, label_indices):
414
+ frame_labels[i, start:end, label_index] = 1.0
415
+
416
+ total_loss = 0.0
417
+ probs = probs.view(-1, probs.size(-1))
418
+ frame_labels = frame_labels.view(-1, frame_labels.size(-1))
419
+ ph_loss = F.cross_entropy(probs, frame_labels.argmax(dim=-1))
420
+ loss = 0
421
+
422
+ for t, t_preds in preds.items():
423
+ mask = length_to_mask(original_lengths - t + 1)
424
+ out = torch.stack(t_preds, dim=-1)
425
+ out = F.log_softmax(out, dim=-1)
426
+ pos_loss = out[..., 0] * mask
427
+ neg_loss = out[..., 1] * mask
428
+
429
+ pos_count = mask.sum()
430
+ neg_count = mask.sum()
431
+
432
+ pos_loss_mean = pos_loss.sum() / pos_count if pos_count > 0 else torch.tensor(0.0, device=pos_loss.device)
433
+ neg_loss_mean = neg_loss.sum() / neg_count if neg_count > 0 else torch.tensor(0.0, device=neg_loss.device)
434
+
435
+ w_pos_neg = torch.softmax(self.w_pos_neg, dim=0)
436
+
437
+ l_pos_neg = torch.stack([pos_loss_mean, -neg_loss_mean])
438
+ loss += -torch.dot(w_pos_neg, l_pos_neg)
439
+
440
+ total_loss = (loss) + 0.2*ph_loss
441
+ sum_mse = torch.tensor(0.0, device=probs.device)
442
+
443
+ if seg is not None and len(total_peaks) > 0:
444
+ seg_tensors = [torch.tensor(s, dtype=torch.float32, device=probs.device) for s in seg]
445
+ for i in range(len(seg_tensors)):
446
+ seg_tensors[i] = seg_tensors[i]*161.34011627906978/16000 # convert to seconds
447
+ peaks_tensors = [torch.tensor(tp[1:], dtype=torch.float32, device=probs.device) for tp in total_peaks]
448
+
449
+ mse = 0.0
450
+ for i in range(len(seg_tensors)):
451
+ mse = mse + F.mse_loss(seg_tensors[i], peaks_tensors[i])
452
+ mse = mse / len(seg_tensors)
453
+
454
+ sum_mse = mse
455
+ total_loss = total_loss + sum_mse
456
+ return total_loss, ph_loss, loss, sum_mse, w_pos_neg, w_phi
457
+
458
+ @hydra.main(config_path='conf/config.yaml', strict=False)
459
+ def main(cfg):
460
+ ds, _, _ = TrainTestDataset.get_datasets(cfg.timit_path)
461
+ spect, seg, phonemes, length, fname = ds[0]
462
+ spect = spect.unsqueeze(0)
463
+ model = NextFrameClassifier(cfg)
464
+ out = model(spect, seg, phonemes, length)
465
+
466
+ if __name__ == "__main__":
467
+ main()
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ espeak
2
+ espeak-ng
predict.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ from glob import glob
3
+ from unittest import case
4
+ import dill
5
+ from argparse import Namespace
6
+ import torch
7
+ import torchaudio
8
+ import torch.nn.functional as F
9
+ from utils import (max_min_norm,
10
+ get_timit_61_phoneme_mappings)
11
+ from next_frame_classifier import NextFrameClassifier
12
+
13
+ from dataloader import spectral_size
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ import os
17
+
18
+ import dutch_preprocess
19
+ from utils import timit_to_leehon_map_MACRO, timit_leehon_39_phonemes, timit_61_phonemes
20
+
21
+ # Cache the loaded model+peak-params by checkpoint path so batch runs (many files,
22
+ # one checkpoint) don't reload ~330MB from disk per file. Output is unchanged.
23
+ _MODEL_CACHE = {}
24
+
25
+ def _load_model(ckpt_path):
26
+ cached = _MODEL_CACHE.get(ckpt_path)
27
+ if cached is not None:
28
+ return cached
29
+ ckpt = torch.load(ckpt_path, map_location=lambda storage, loc: storage)
30
+ hp = ckpt["hparams"]
31
+ model = NextFrameClassifier(hp)
32
+ try:
33
+ weights = ckpt["state_dict"]
34
+ except Exception:
35
+ weights = ckpt["model_state_dict"]
36
+ weights = {k.replace("NFC.", ""): v for k, v in weights.items()}
37
+ model.load_state_dict(weights)
38
+ model.eval()
39
+ peak_detection_params = dill.loads(ckpt['peak_detection_params'])['cpc_1']
40
+ _MODEL_CACHE.clear() # keep only the most-recent checkpoint (bounded memory)
41
+ _MODEL_CACHE[ckpt_path] = (model, peak_detection_params)
42
+ return model, peak_detection_params
43
+
44
+ def main_predict(wav, ckpt, w_phi, language="english", annotation="phn", no_plots=False):
45
+ print(f"running inference on: {wav}")
46
+ print(f"running inferece using ckpt: {ckpt}")
47
+ print("\n\n", 90 * "-")
48
+
49
+ # Optional plot suppression for fast batch runs. Patches plt.savefig for
50
+ # the duration of this call so plots inside utils.phoneme_alignment are
51
+ # skipped too, without having to modify utils.py.
52
+ _orig_savefig = plt.savefig if no_plots else None
53
+ if no_plots:
54
+ plt.savefig = lambda *a, **k: None
55
+
56
+ model, peak_detection_params = _load_model(ckpt)
57
+ # peak_detection_params["prominence"] = prominence # Unused
58
+ # load data
59
+ audio, sr = torchaudio.load(wav)
60
+ assert sr == 16000, "model was trained with audio sampled at 16khz, please downsample."
61
+ audio = audio[0]
62
+ # audio = audio.unsqueeze(0)
63
+
64
+ base_dir = os.path.dirname(wav)
65
+ base_name = os.path.basename(wav).split('.')[0]
66
+ # search_pattern = os.path.join(base_dir, f"{base_name}*.phn")
67
+ # search_pattern = os.path.join(base_dir, f"{base_name}*.wrd")
68
+ # search_pattern = os.path.join(base_dir, f"{base_name}*.word")
69
+ search_pattern = os.path.join(base_dir, f"{base_name}*.{annotation}")
70
+ matching_files = glob(search_pattern)
71
+ if matching_files:
72
+ phn_path = matching_files[0]
73
+ else:
74
+ print("No matching .phn file found. Using default naming convention.")
75
+ phn_path = wav.replace("wav", "phn")
76
+
77
+ # load audio
78
+ audio_len = len(audio)
79
+ spectral_len = spectral_size(audio_len)
80
+ len_ratio = (audio_len / spectral_len)
81
+
82
+ # load labels -- segmentation and phonemes
83
+ with open(phn_path, "r") as f:
84
+ lines = f.readlines()
85
+ lines = list(map(lambda line: line.split(), lines))
86
+
87
+ # get segment times
88
+ times = torch.FloatTensor(list(map(lambda line: int(float(line[1]) / len_ratio), lines)))[:-1] # don't count end time as boundary
89
+ # times = torch.FloatTensor(list(map(lambda line: int(int(line[1]) / len_ratio), lines)))[:-1] # don't count end time as boundary
90
+ times_sec = torch.FloatTensor(list(map(lambda line: (float(line[1]) / sr), lines)))[:-1] # don't count end #sr = 16000 in TIMIT
91
+ # times_sec = torch.FloatTensor(list(map(lambda line: (int(line[1]) / sr), lines)))[:-1] # don't count end #sr = 16000 in TIMIT
92
+
93
+ # get phonemes in each segment (for K times there should be K+1 phonemes)
94
+ phonemes = list(map(lambda line: line[2].strip(), lines))
95
+
96
+ # Original input labels (one per .phn/.wrd/.txt line) — kept around for the
97
+ # truth-boundary text on plots. After G2P the `phonemes` variable is
98
+ # flattened into LH39 phonemes whose count differs from len(times), so we
99
+ # cache the per-line labels here before that transformation.
100
+ truth_labels = list(phonemes)
101
+
102
+ if language == "dutch":
103
+ lh39_ph = []
104
+ for IFA_ph in phonemes:
105
+ print(f"\nINPUT: {IFA_ph}")
106
+ # output = dutch_preprocess.aligner_pipeline(timit_to_leehon_map_MACRO[IFA_ph.lower()])
107
+ output = dutch_preprocess.aligner_pipeline(IFA_ph if IFA_ph.lower() not in timit_61_phonemes else timit_to_leehon_map_MACRO[IFA_ph.lower()])
108
+ # output = dutch_preprocess.aligner_pipeline(IFA_ph)
109
+
110
+ # # FOR WORDS: #not good
111
+ # lh39_ph.append(output[0]["lh39"])
112
+
113
+ # FOR PHONEMES:
114
+ lh39_ph.append([x["lh39"] for x in output])
115
+ if not output:
116
+ print("Results: None")
117
+ print(phonemes)
118
+ print(f"Dutch IPA to LH39 mapping: {lh39_ph}")
119
+
120
+ # try
121
+ phonemes = np.hstack(lh39_ph).tolist()
122
+
123
+ audio, seg, phonemes, length = audio.unsqueeze(0), [times.tolist()], [phonemes], [audio_len/len_ratio] #[spectral_size(len(audio))]
124
+
125
+
126
+
127
+ with torch.no_grad():
128
+ model.eval()
129
+
130
+ # preds,original_lengths, probs, frame_labels = model(audio,None,phonemes,length)
131
+ # ------- Sept 10 - check with truth preds no truth for nce ---------------
132
+ preds,original_lengths, probs, frame_labels, _,preds_peaks, w_phi = model(audio,None,phonemes,length)
133
+ # preds,original_lengths, probs, frame_labels, _,preds_peaks, w_phi = model(audio,seg,phonemes,length)
134
+ # ------- ------------------------------------------------- ---------------
135
+
136
+ phoneme_to_idx, idx_to_phoneme = get_timit_61_phoneme_mappings()
137
+
138
+
139
+ phoneme_labels = [idx_to_phoneme[i] for i in range(39)]
140
+ # phoneme_labels = [idx_to_phoneme[i] for i in range(61)]
141
+ # phoneme_labels = [idx_to_phoneme[i] for i in range(41)]
142
+ # probs_real = probs #F.softmax(probs, dim=-1)
143
+ probs_real = F.softmax(probs, dim=-1).squeeze(0).detach().numpy()
144
+
145
+ out_dir = os.path.dirname(wav)
146
+ base_name = os.path.basename(wav).replace('.wav', '')
147
+
148
+ plt.figure(figsize=(15, 5))
149
+ plt.imshow(probs_real.T, aspect='auto', cmap='viridis')
150
+ plt.colorbar(label='Probability')
151
+ plt.xlabel('Frame Index')
152
+ plt.ylabel('Phoneme')
153
+ plt.yticks(ticks=range(39), labels=phoneme_labels)
154
+ plt.title('Frame-wise Label Probability Map')
155
+ # Truth-boundary axvlines + per-segment label text. truth_labels is the
156
+ # original (pre-G2P) per-line input labels, so its length matches `times`.
157
+ for i, s in enumerate(times):
158
+ s_val = float(s)
159
+ plt.axvline(x=s_val, color='red', linestyle='--', linewidth=1,
160
+ label='Truth boundary' if i == 0 else "")
161
+ if i < len(truth_labels):
162
+ plt.text(s_val, probs_real.shape[1] + 1, truth_labels[i],
163
+ color='red', rotation=90, va='top', ha='center', fontsize=8)
164
+ plt.savefig(os.path.join(out_dir, f"{base_name}_probs.png"))
165
+ plt.close()
166
+
167
+ # Latent boundary scores (CNN output) — used for the boundaries plot.
168
+ preds = preds[1][0]
169
+ preds = max_min_norm(preds)
170
+ preds_np = preds.detach().numpy()[0]
171
+ median_h = np.median(preds_np)
172
+ preds_np = preds_np - median_h
173
+
174
+ # Predicted boundary timestamps (in seconds).
175
+ preds = torch.tensor(preds_peaks[0], dtype=torch.float32)
176
+ print(f"predicted boundaries (s): {preds}")
177
+
178
+ # Marker height: scale to the *typical* peak of the latent score so the
179
+ # triangles sit roughly on top of peaks instead of dwarfing them or
180
+ # vanishing into the noise floor. The signal is sparse, so we filter out
181
+ # near-zero values and take the median of what's left — robust to both
182
+ # outlier spikes and the long tail of zero-ish frames.
183
+ abs_p = np.abs(preds_np) if preds_np.size else np.array([])
184
+ if abs_p.size:
185
+ noise_thresh = 0.05 * float(np.max(abs_p))
186
+ peaks = abs_p[abs_p > noise_thresh]
187
+ marker_h = float(np.median(peaks)) if peaks.size else float(np.max(abs_p))
188
+ else:
189
+ marker_h = 0.1
190
+ if marker_h <= 0:
191
+ marker_h = 0.1
192
+
193
+ # Truth-boundary marker signal (peak per truth boundary, zero elsewhere).
194
+ signal = np.zeros(int(original_lengths[0]))
195
+ signal_max_idx = signal.shape[0] - 1
196
+ for t in times:
197
+ idx = min(int(t), signal_max_idx)
198
+ signal[idx] = marker_h
199
+ times_clipped = [t for t in times if float(t) <= signal_max_idx]
200
+
201
+ plt.figure(figsize=(12, 6))
202
+ plt.plot(signal, marker='*', linestyle='-', label='Truth boundary marker')
203
+ derivative_preds_np = np.diff(preds_np)
204
+ derivative_preds_np = np.concatenate([[0], derivative_preds_np])
205
+ plt.plot(range(len(derivative_preds_np)), derivative_preds_np,
206
+ marker='o', label='Derivative of latent score', color='magenta')
207
+ plt.plot(range(len(preds_np)), preds_np,
208
+ marker='*', label='Latent score', color='red')
209
+
210
+ preds_plot = np.zeros(int(original_lengths[0]))
211
+ for pred in preds:
212
+ idx = int(pred * sr / len_ratio)
213
+ if idx <= len(preds_plot) - 1:
214
+ preds_plot[idx] = marker_h
215
+ plt.plot(range(len(preds_plot)), preds_plot,
216
+ marker='^', label='Predicted boundary', linestyle='None')
217
+
218
+ y_top = plt.ylim()[1]
219
+ for i, s in enumerate(times_clipped):
220
+ s_val = float(s)
221
+ plt.axvline(x=s_val, color='red', linestyle='--', linewidth=1,
222
+ label='Truth boundary' if i == 0 else "")
223
+ if i < len(truth_labels):
224
+ plt.text(s_val, y_top, truth_labels[i], color='red',
225
+ rotation=90, va='top', ha='center', fontsize=8)
226
+
227
+ plt.xlabel('Frame Index')
228
+ plt.ylabel('Score')
229
+ plt.title('Predicted Boundaries')
230
+ plt.legend(loc='upper right')
231
+ # Clip the y-axis to a few × typical-peak height so single outlier spikes
232
+ # don't visually squash the rest of the signal.
233
+ y_half = max(marker_h * 4.0, 0.1)
234
+ plt.ylim(-y_half, y_half)
235
+ plt.savefig(os.path.join(out_dir, f"{base_name}_boundaries.png"))
236
+ plt.close()
237
+
238
+ pred_bound, truth_bound = preds, times_sec
239
+ mapped_ph = lh39_ph if language == "dutch" else None
240
+
241
+ if no_plots:
242
+ plt.savefig = _orig_savefig
243
+
244
+ return pred_bound, truth_bound, mapped_ph
245
+
246
+ _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
247
+ DEFAULT_CKPT_ENGLISH = os.path.join(_SCRIPT_DIR, "pretrained_models", "falcon_timit_english.pt")
248
+ # The joint TIMIT+Buckeye model generalizes best to unseen languages — it is the
249
+ # strongest checkpoint on every multilingual test set (Dutch/German/Hebrew,
250
+ # phoneme and word) — so it is the default for the multilingual / cross-lingual path.
251
+ DEFAULT_CKPT_MULTILINGUAL = os.path.join(_SCRIPT_DIR, "pretrained_models", "falcon_joint_multilingual.pt")
252
+
253
+ def resolve_internal_language(lang: str, mode: str, annotation: str) -> str:
254
+ """
255
+ Map user-facing (--lang, --mode, --annotation) to the internal `language`
256
+ flag main_predict() understands.
257
+
258
+ 'english' = no G2P; assumes labels are already TIMIT-39 phonemes.
259
+ 'dutch' = G2P pipeline (panphon-based mapping). Used for any non-English
260
+ language, word-level mode, or plain-text input.
261
+ """
262
+ if lang == "english" and mode == "phoneme" and annotation.lower() == "phn":
263
+ return "english"
264
+ return "dutch"
265
+
266
+ def resolve_default_ckpt(lang: str) -> str:
267
+ return DEFAULT_CKPT_MULTILINGUAL if lang == "multilingual" else DEFAULT_CKPT_ENGLISH
268
+
269
+ if __name__ == "__main__":
270
+ parser = argparse.ArgumentParser(description='Unsupervised segmentation inference script')
271
+ parser.add_argument('--wav', help='path to wav file')
272
+ parser.add_argument('--ckpt', default=None,
273
+ help='Path to checkpoint file. If omitted, uses '
274
+ 'pretrained_models/falcon_timit_english.pt for --lang english '
275
+ 'or falcon_joint_multilingual.pt for --lang multilingual '
276
+ '(the joint TIMIT+Buckeye model is best for cross-lingual zero-shot).')
277
+
278
+ parser.add_argument('--mode', type=str, default='phoneme', choices=['phoneme', 'word'],
279
+ help='Alignment granularity: "phoneme" = phoneme-level alignment (default). '
280
+ '"word" = word-level alignment (zero-shot, no additional training).')
281
+ parser.add_argument('--lang', type=str, default='english', choices=['english', 'multilingual'],
282
+ help='Language setting: "english" = trained English phoneme alignment (default). '
283
+ '"multilingual" = any non-English language (zero-shot cross-lingual).')
284
+ parser.add_argument('--annotation', type=str, default='phn',
285
+ help='Annotation file extension to search for (e.g. phn, wrd, word, txt). Default: phn')
286
+ parser.add_argument('--no-plots', action='store_true',
287
+ help='Skip per-file diagnostic plots (probs/logits/boundaries/dp_matrix) for faster runs.')
288
+ args = parser.parse_args()
289
+
290
+ ckpt = args.ckpt or resolve_default_ckpt(args.lang)
291
+ language = resolve_internal_language(args.lang, args.mode, args.annotation)
292
+ main_predict(args.wav, ckpt, w_phi=0.5, language=language,
293
+ annotation=args.annotation, no_plots=args.no_plots)
requirements.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FALCON Space — pinned to the working local env (Python 3.8) + pynini for MFA-like G2P
2
+ torch==2.4.1
3
+ torchaudio==2.4.1
4
+ hydra-core==0.11.3
5
+ omegaconf==1.4.1
6
+ wandb==0.8.24
7
+ torch-optimizer==0.0.1a12
8
+ librosa==0.8.1
9
+ soundfile==0.13.1
10
+ numpy==1.22.4
11
+ scipy==1.7.3
12
+ scikit-learn==1.3.2
13
+ panphon==0.21.0
14
+ dill==0.3.1.1
15
+ boltons==20.0.0
16
+ matplotlib==3.4.3
17
+ memory-profiler==0.61.0
18
+ tqdm==4.62.3
19
+ gradio==4.44.1
20
+ huggingface_hub==0.36.2
21
+ TextGrid>=1.5
22
+ pynini==2.1.6
utils.py ADDED
@@ -0,0 +1,691 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import torch
4
+ import torch.nn as nn
5
+ import numpy as np
6
+ import matplotlib.pyplot as plt
7
+ import time
8
+ from scipy.signal import find_peaks
9
+ import wandb
10
+ from tqdm import tqdm
11
+ import concurrent.futures
12
+ from typing import List, Sequence, Union
13
+ import time
14
+ from memory_profiler import profile
15
+
16
+ # Optionally redirect the dp_matrix plot to a specific directory (used by demo).
17
+ # Set via set_dp_matrix_out_dir() before calling inference; reset to None after.
18
+ _dp_matrix_out_dir = None
19
+
20
+ def set_dp_matrix_out_dir(path):
21
+ global _dp_matrix_out_dir
22
+ _dp_matrix_out_dir = path
23
+
24
+ timit_leehon_39_phonemes = [
25
+ 'ao', 'ae', 'ah','aw', 'er', 'ay',
26
+ 'b', 'sil', 'ch', 'd', 'dh', 'dx', 'eh', 'el', 'm', 'en', 'ng', 'ey',
27
+ 'f', 'g', 'hh', 'ih', 'iy', 'jh', 'k', 'v', 'w', 'y', 'z', 'sh', 't', 'r', 's', 'th','uh', 'uw', 'oy', 'ow','p'
28
+ ]
29
+
30
+ timit_61_phonemes = [
31
+ 'aa', 'ae', 'ah', 'ao', 'aw', 'ax', 'ax-h', 'axr', 'ay',
32
+ 'b', 'bcl', 'ch', 'd', 'dcl', 'dh', 'dx', 'eh', 'el', 'em', 'en', 'eng', 'epi', 'er', 'ey',
33
+ 'f', 'g', 'gcl', 'h#', 'hh', 'hv', 'ih', 'ix', 'iy', 'jh', 'k', 'kcl', 'm', 'n', 'ng', 'l',
34
+ 'nx', 'ow', 'oy', 'p', 'pau', 'pcl', 'q', 'r', 's', 'sh', 't', 'tcl', 'th', 'uh', 'uw','ux',
35
+ 'v', 'w', 'y', 'z', 'zh'
36
+ ]
37
+
38
+ # Create mappings
39
+ # phoneme_to_idx = {phoneme: idx for idx, phoneme in enumerate(timit_61_phonemes)}
40
+ phoneme_to_idx_MACRO = {phoneme: idx for idx, phoneme in enumerate(timit_leehon_39_phonemes)}
41
+
42
+ idx_to_phoneme_MACRO = {idx: phoneme for phoneme, idx in phoneme_to_idx_MACRO.items()}
43
+ timit_to_leehon_map_MACRO = {
44
+ 'aa': 'ao', 'ae': 'ae', 'ah': 'ah', 'ao': 'ao', 'aw': 'aw', 'ax': 'ah', 'ax-h': 'ah', 'axr': 'er', 'ay': 'ay',
45
+ 'b': 'b', 'bcl': 'sil', 'ch': 'ch', 'd': 'd', 'dcl': 'sil', 'dh': 'dh', 'dx': 'dx', 'eh': 'eh', 'el': 'el',
46
+ 'em': 'm', 'en': 'en', 'eng': 'ng', 'epi': 'sil', 'er': 'er', 'ey': 'ey', 'f': 'f', 'g': 'g', 'gcl': 'sil',
47
+ 'h#': 'sil', 'hh': 'hh', 'hv': 'hh', 'ih': 'ih', 'ix': 'ih', 'iy': 'iy', 'jh': 'jh', 'k': 'k', 'kcl': 'sil',
48
+ 'l': 'el', 'm': 'm', 'n': 'en', 'ng': 'ng', 'nx': 'en', 'ow': 'ow', 'oy': 'oy', 'p': 'p', 'pau': 'sil', 'pcl': 'sil',
49
+ 'q': 't', 'qcl': 'sil', 'r': 'r', 's': 's', 'sh': 'sh', 't': 't', 'tcl': 'sil', 'th': 'th', 'uh': 'uh', 'uw': 'uw',
50
+ 'ux': 'uw', 'v': 'v', 'w': 'w', 'y': 'y', 'z': 'z', 'zh': 'sh',
51
+ }
52
+
53
+ def create_truth_probs_real(segments, phonemes, phoneme_to_index, num_frames):
54
+ segments = [0] + list(segments)
55
+ num_phonemes = len(phoneme_to_index)
56
+ probs_real = torch.zeros((num_frames, num_phonemes), dtype=torch.float32)
57
+ for seg_idx in range(len(phonemes)):
58
+ start = int(segments[seg_idx])
59
+ end = int(segments[seg_idx + 1]) if seg_idx + 1 < len(segments) else num_frames
60
+ if end > start:
61
+ ph_label = phonemes[seg_idx].lower()
62
+ ph_index = phoneme_to_index.get(ph_label, phoneme_to_index.get('sil', 0))
63
+ probs_real[start:end, ph_index] = 1.0
64
+ return probs_real
65
+
66
+
67
+ # ------------------------------- ablations -------------------------------
68
+
69
+ # phoneme alignment with classic (hard) DP
70
+ def phoneme_alignment_Hard_DP(p_seq, w_phi, original_lengths, len_ratio, derivative_preds_np, probs_real):
71
+ # Gamma is kept for signature consistency but not used in hard DP
72
+ gamma = 1e-20
73
+ T = int(original_lengths[0])
74
+ n = len(p_seq)
75
+ device = derivative_preds_np.device
76
+
77
+ if isinstance(probs_real, np.ndarray):
78
+ probs_real = torch.tensor(probs_real, device=device)
79
+ cumsum_probs = torch.cumsum(probs_real, dim=0)
80
+
81
+ phoneme_mappings = {p.lower(): timit_to_leehon_map_MACRO.get(p.lower(), 'sil') if p.lower() not in timit_leehon_39_phonemes else p.lower() for p in p_seq}
82
+ derivatives = torch.cat([torch.tensor([0], device=device), torch.diff(derivative_preds_np, dim=0)])
83
+
84
+ # Initialize DP matrix with very low value
85
+ dp_mat = torch.full((n, T, T), float(-1e9), device=device)
86
+ p_idx0 = phoneme_to_idx_MACRO[phoneme_mappings[p_seq[0].lower()]]
87
+
88
+ # Initial state for first phoneme
89
+ t_e = torch.arange(T, device=device)
90
+ dp_mat[0, 0, :] = (
91
+ w_phi[0] * compute_phi_1(derivatives, 0, t_e)
92
+ )
93
+
94
+ # Forward Pass
95
+ for i in tqdm(range(1, n)):
96
+ p_idx = phoneme_to_idx_MACRO[phoneme_mappings[p_seq[i].lower()]]
97
+ t_start = torch.arange(T, device=device)
98
+ t_end = torch.arange(T, device=device)
99
+ t_start_grid, t_end_grid = torch.meshgrid(t_start, t_end, indexing='ij')
100
+ valid_mask = t_start_grid < t_end_grid
101
+
102
+ phi1_dev = compute_phi_1(derivatives, t_start_grid, t_end_grid)
103
+ phi2 = compute_phi_2(cumsum_probs, p_idx, t_start_grid, t_end_grid)
104
+ total_phi = w_phi[0] * phi1_dev
105
+
106
+ prev_scores = torch.full((T, T), float(-1e9), device=device)
107
+
108
+ for t_end_val in range(T):
109
+ valid_starts = t_start[t_start < t_end_val]
110
+ if valid_starts.numel() == 0:
111
+ continue
112
+
113
+ # --- CLASSIC DP CHANGE ---
114
+ # Instead of LogSumExp (Soft-Max), use Hard Max
115
+ prev = dp_mat[i-1, :valid_starts[-1]+1, valid_starts]
116
+ max_prev, _ = torch.max(prev, dim=0)
117
+ prev_scores[valid_starts, t_end_val] = max_prev
118
+
119
+ dp_mat[i] = torch.where(valid_mask, total_phi + prev_scores, torch.full_like(total_phi, float(-1e9)))
120
+
121
+ # Backtracking (Classic Argmax)
122
+ best_start_times = torch.zeros((n), dtype=derivative_preds_np.dtype, device=device)
123
+ best_prev_t_end = T - 1
124
+
125
+ for i in range(n):
126
+ cur_ph = n - 1 - i
127
+ # Find the exact index that gave the maximum score
128
+ scores = dp_mat[cur_ph, :, best_prev_t_end]
129
+
130
+ # --- CLASSIC DP CHANGE ---
131
+ # Instead of expected_idx (Soft-Argmax), use Hard Argmax
132
+ best_t_start = torch.argmax(scores)
133
+
134
+ best_start_times[cur_ph] = best_t_start.to(derivative_preds_np.dtype)
135
+ best_prev_t_end = int(best_t_start.item())
136
+
137
+ # Visualization Code (unchanged logic, updated labels)
138
+ dp_mat_cpu = dp_mat.detach().cpu()
139
+ best_start_times_cpu = best_start_times.detach().cpu().numpy()
140
+ dp_to_plot = dp_mat_cpu.max(dim=1)[0].numpy()
141
+ masked_dp = np.ma.masked_where(dp_to_plot <= -1e8, dp_to_plot)
142
+
143
+ plt.figure(figsize=(12, 6))
144
+ cmap = plt.cm.viridis
145
+ cmap.set_bad(color='white')
146
+ plt.imshow(masked_dp, aspect='auto', origin='lower', cmap=cmap)
147
+ plt.colorbar(label='Hard DP Score')
148
+ plt.xlabel('End time (frame)')
149
+ plt.ylabel('Phoneme index')
150
+ plt.title('Classic (Hard) DP Matrix with Best Path')
151
+ plt.plot(best_start_times_cpu, range(len(best_start_times_cpu)), 'r.-', label='Argmax path')
152
+ plt.legend()
153
+ plt.tight_layout()
154
+ plt.savefig('dp_matrix_hard_classic.png')
155
+ plt.close()
156
+
157
+ return best_start_times
158
+
159
+ # ------------------second ablations - naive peak detection ------------------
160
+
161
+ from scipy.signal import find_peaks
162
+
163
+ def phoneme_alignment_naive_peak_detection(p_seq, w_phi, original_lengths, len_ratio, derivative_preds_np, probs_real):
164
+ """
165
+ Ablation version: Replaces DP with Naive Scipy Peak Detection.
166
+ """
167
+ gamma = 1e-20
168
+ T = int(original_lengths[0])
169
+ n = len(p_seq)
170
+ device = derivative_preds_np.device
171
+
172
+ # --- Keep identical preprocessing to ensure 'plug & play' ---
173
+ if isinstance(probs_real, np.ndarray):
174
+ probs_real = torch.tensor(probs_real, device=device)
175
+
176
+ # We don't actually need cumsum_probs or phoneme_mappings for naive peak detection,
177
+ # but we keep them defined to avoid any potential scope issues if you add code back.
178
+ cumsum_probs = torch.cumsum(probs_real, dim=0)
179
+ signal = derivative_preds_np.detach().cpu().numpy().flatten()
180
+
181
+ # --- Naive Peak Detection ---
182
+ # To get exactly 'n' boundaries for 'n' phonemes, we pick the top n most prominent peaks.
183
+ peaks, properties = find_peaks(signal, prominence=0.05)
184
+
185
+ peak_heights = signal[peaks]
186
+
187
+ # Sort peaks by height and take the top 'n'
188
+ top_indices = np.argsort(peak_heights)[-n:]
189
+ best_peaks = np.sort(peaks[top_indices])
190
+ if len(best_peaks) < n:
191
+ filler = np.linspace(0, T-1, n)
192
+ best_peaks = filler # Fallback
193
+ best_start_times = torch.tensor(best_peaks, dtype=derivative_preds_np.dtype, device=device)
194
+
195
+ # --- Mock DP Matrix for Plotting ---
196
+ dp_mat = torch.full((n, T, T), float(-1e9), device=device)
197
+ for i, peak_time in enumerate(best_peaks):
198
+ dp_mat[i, :, int(peak_time)] = 1.0
199
+
200
+ # --- Identical Plotting Logic ---
201
+ dp_mat_cpu = dp_mat.detach().cpu()
202
+ best_start_times_cpu = best_start_times.detach().cpu().numpy()
203
+ dp_to_plot = dp_mat_cpu.max(dim=1)[0].numpy()
204
+
205
+ masked_dp = np.ma.masked_where(dp_to_plot <= -1e8, dp_to_plot)
206
+
207
+ plt.figure(figsize=(12, 6))
208
+ cmap = plt.cm.viridis
209
+ cmap.set_bad(color='white')
210
+
211
+ plt.imshow(masked_dp, aspect='auto', origin='lower', cmap=cmap)
212
+ plt.colorbar(label='Peak Detection (Naive)')
213
+ plt.xlabel('End time (frame)')
214
+ plt.ylabel('Phoneme index')
215
+ plt.title('Naive Peak Detection (Ablation)')
216
+ plt.plot(best_start_times_cpu, range(len(best_start_times_cpu)), 'r.-', label='Detected Peaks')
217
+ plt.legend()
218
+ plt.tight_layout()
219
+ save_path = 'peak_detection_ablation.png'
220
+ plt.savefig(save_path)
221
+ plt.close()
222
+
223
+ print(f"Ablation plot saved as {save_path}")
224
+
225
+ return best_start_times
226
+
227
+ # ------------------------- phoneme alignment main ------------------------
228
+ def phoneme_alignment(p_seq, w_phi, original_lengths, len_ratio, derivative_preds_np, probs_real):
229
+ gamma = 1e-20
230
+ T = int(original_lengths[0])
231
+ n = len(p_seq)
232
+ device = derivative_preds_np.device
233
+
234
+ if isinstance(probs_real, np.ndarray):
235
+ probs_real = torch.tensor(probs_real, device=device)
236
+ cumsum_probs = torch.cumsum(probs_real, dim=0)
237
+
238
+
239
+
240
+ phoneme_mappings = {p.lower(): timit_to_leehon_map_MACRO.get(p.lower(), 'sil') if p.lower() not in timit_leehon_39_phonemes else p.lower() for p in p_seq}
241
+ derivatives = torch.cat([torch.tensor([0], device=derivative_preds_np.device), torch.diff(derivative_preds_np, dim=0)])
242
+
243
+ dp_mat = torch.full((n, T, T), float(-1e9), device=device)
244
+
245
+ p_idx0 = phoneme_to_idx_MACRO[phoneme_mappings[p_seq[0].lower()]]
246
+
247
+ # Vectorized init for first phoneme
248
+ t_e = torch.arange(T, device=device)
249
+ dp_mat[0, 0, :] = (
250
+ w_phi[0] * compute_phi_1(derivatives, 0, t_e)
251
+ + w_phi[1] * compute_phi_1(derivatives, 0, t_e)
252
+
253
+ )
254
+
255
+ for i in tqdm(range(1, n)):
256
+ p_idx = phoneme_to_idx_MACRO[phoneme_mappings[p_seq[i].lower()]]
257
+ # Vectorized over t_start and t_end
258
+ t_start = torch.arange(T, device=device)
259
+ t_end = torch.arange(T, device=device)
260
+ t_start_grid, t_end_grid = torch.meshgrid(t_start, t_end, indexing='ij')
261
+ valid_mask = t_start_grid < t_end_grid
262
+
263
+ phi1_dev = compute_phi_1(derivatives, t_start_grid, t_end_grid)
264
+ phi1 = compute_phi_1(derivative_preds_np, t_start_grid, t_end_grid)
265
+ phi2 = compute_phi_2(cumsum_probs, p_idx, t_start_grid, t_end_grid)
266
+ total_phi = w_phi[0] * phi1_dev + w_phi[1] * phi2
267
+
268
+ # Max over all possible previous end times.
269
+ # Vectorized equivalent of the original per-t_end loop: for each previous
270
+ # end time s, logsumexp over dp_mat[i-1]'s start-rows is the SAME regardless
271
+ # of t_end (invalid spans start>=end are -1e9 and never contribute), so the
272
+ # O(T) inner loop over t_end collapses to one column-wise logsumexp + mask.
273
+ # Bit-identical output; ~T x faster on long utterances.
274
+ col_lse = torch.logsumexp(dp_mat[i-1] / gamma, dim=0) * gamma # (T,) over start rows
275
+ prev_scores = torch.where(
276
+ valid_mask,
277
+ col_lse.unsqueeze(1).expand(T, T),
278
+ torch.full((T, T), float(-1e9), device=device),
279
+ )
280
+ dp_mat[i] = torch.where(valid_mask, total_phi + prev_scores, torch.full_like(total_phi, float(-1e9)))
281
+
282
+ # Backtracking
283
+ best_start_times = torch.zeros((n), dtype=derivative_preds_np.dtype, device=device)
284
+ best_prev_t_end = T-1
285
+ for i in range(n):
286
+ cur_ph = n-1-i
287
+ scores = dp_mat[cur_ph, :, best_prev_t_end]
288
+ soft_weights = torch.softmax(scores / gamma, dim=0)
289
+ expected_idx = (soft_weights * torch.arange(T, device=device, dtype=derivative_preds_np.dtype)).sum()
290
+ best_start_times[cur_ph] = expected_idx
291
+ best_prev_t_end = int(expected_idx.round().item())
292
+
293
+ # DP-matrix figure is a debug artifact only; best_start_times (the return
294
+ # value) is already computed above. Skip entirely unless an output dir is set
295
+ # (the web demo sets it). Saves ~570ms/utterance in training/eval.
296
+ if _dp_matrix_out_dir is not None:
297
+ dp_mat_cpu = dp_mat.detach().cpu()
298
+ best_start_times_cpu = best_start_times.detach().cpu().numpy()
299
+ dp_to_plot = dp_mat_cpu.max(dim=1)[0].numpy()
300
+ masked_dp = np.ma.masked_where(dp_to_plot <= -1e8, dp_to_plot) # mask all values <= -1e8
301
+
302
+ plt.figure(figsize=(12, 6))
303
+ cmap = plt.cm.viridis
304
+ cmap.set_bad(color='white')
305
+ real_min = masked_dp.min()
306
+ real_max = masked_dp.max()
307
+ # Plot DP matrix (max over start times)
308
+ plt.imshow(masked_dp, aspect='auto', origin='lower', cmap=cmap, vmin=real_min, vmax=real_max)
309
+ plt.colorbar(label='DP Score (max over start)')
310
+ plt.xlabel('End time (frame)')
311
+ plt.ylabel('Phoneme index')
312
+ plt.title('DP Matrix with Best Path')
313
+ # Overlay best_start_times as a red line
314
+ plt.plot(best_start_times_cpu, range(len(best_start_times_cpu)), 'r.-', label='Best start times')
315
+ plt.legend()
316
+ plt.tight_layout()
317
+ _save_path = os.path.join(_dp_matrix_out_dir or '.', 'dp_matrix_with_path.png')
318
+ plt.savefig(_save_path)
319
+ print(f"DP matrix with path plot saved as {_save_path}")
320
+
321
+ return best_start_times
322
+
323
+ def compute_phi_1(derivative_preds_np: torch.Tensor, t_start: Union[torch.Tensor, int], t_end: Union[torch.Tensor, int]) -> torch.Tensor:
324
+ """
325
+ Computes phi_1 for dynamic programming.
326
+ t_start and t_end can be scalars or tensors of the same shape.
327
+ Returns a tensor of scores.
328
+ """
329
+ # Ensure t_start and t_end are tensors
330
+ t_start = torch.as_tensor(t_start, device=derivative_preds_np.device)
331
+ t_end = torch.as_tensor(t_end, device=derivative_preds_np.device)
332
+ # Broadcast to same shape
333
+ t_start, t_end = torch.broadcast_tensors(t_start, t_end)
334
+ # Valid indices
335
+ valid = (t_end < derivative_preds_np.shape[0]-1) & (t_start < derivative_preds_np.shape[0]-1) & (t_end > 0) & (t_start > 0)
336
+ score = torch.zeros_like(t_start, dtype=derivative_preds_np.dtype, device=derivative_preds_np.device)
337
+
338
+ eps = 1e-6
339
+ tanh_scale = 1e-3 #1e-2 #0.5
340
+ if valid.any():
341
+
342
+ # start_pos -
343
+ idx_s = t_start[valid].long()
344
+ s_center = torch.tanh(tanh_scale * derivative_preds_np[idx_s])
345
+ s_prev = torch.tanh(tanh_scale * derivative_preds_np[idx_s -1])
346
+ s_next = torch.tanh(tanh_scale * derivative_preds_np[idx_s +1])
347
+ delta_prev_s = s_center - s_prev
348
+ delta_next_s = s_center - s_next
349
+ scores_zerocross_s = (1-torch.sqrt(s_center**2)) + torch.sqrt(delta_prev_s **2 + eps) + torch.sqrt(delta_next_s**2 + eps)
350
+ # orig -
351
+ score[valid] += scores_zerocross_s
352
+
353
+ # end_pos -
354
+ idx_e = t_end[valid].long()
355
+ e_center = torch.tanh(tanh_scale * derivative_preds_np[idx_e]) #do i need this? not sure
356
+ e_prev = torch.tanh(tanh_scale * derivative_preds_np[idx_e -1])
357
+ e_next = torch.tanh(tanh_scale * derivative_preds_np[idx_e +1])
358
+ delta_prev_e = e_center - e_prev
359
+ delta_next_e = e_center - e_next
360
+ scores_zerocross_e = (1-torch.sqrt(e_center**2)) + torch.sqrt(delta_prev_e **2 + eps) + torch.sqrt(delta_next_e**2 + eps)
361
+ # orig -
362
+ score[valid] += scores_zerocross_e
363
+ return score
364
+
365
+ def compute_phi_2(cumsum_probs: torch.Tensor, p: int, t_start: Union[torch.Tensor, int], t_end: Union[torch.Tensor, int]) -> torch.Tensor:
366
+ """
367
+ Computes phi_2 for dynamic programming.
368
+ t_start and t_end can be scalars or tensors of the same shape.
369
+ Returns a tensor of scores.
370
+ """
371
+ t_start = torch.as_tensor(t_start, device=cumsum_probs.device)
372
+ t_end = torch.as_tensor(t_end, device=cumsum_probs.device)
373
+ t_start, t_end = torch.broadcast_tensors(t_start, t_end)
374
+ # Valid indices
375
+ valid = (t_end < cumsum_probs.shape[0]) & (t_start < cumsum_probs.shape[0]) & (t_end > 0) & (t_start >= 0)
376
+ probs_score = torch.zeros_like(t_start, dtype=cumsum_probs.dtype, device=cumsum_probs.device)
377
+ # Only assign where valid
378
+ probs_score[valid] = cumsum_probs[t_end[valid], p] - torch.where(
379
+ t_start[valid] > 0,
380
+ cumsum_probs[t_start[valid], p],
381
+ torch.zeros_like(t_start[valid], dtype=cumsum_probs.dtype, device=cumsum_probs.device)
382
+ )
383
+ lengths = (t_end - t_start).clamp(min=1)
384
+ probs_score[valid] = probs_score[valid] / lengths[valid]
385
+ return (probs_score)
386
+
387
+ def best_phoneme_for_segments(cumsum_probs: torch.Tensor, t_start: torch.Tensor, t_end: torch.Tensor):
388
+ """
389
+ For each (t_start, t_end) pair (tensors broadcasted to same shape),
390
+ compute the average probability per phoneme over the segment and return:
391
+ - max_vals: tensor of shape (pairs,) with the max average prob per pair
392
+ - max_idx: LongTensor of shape (pairs,) with the argmax phoneme index per pair
393
+ """
394
+ device = cumsum_probs.device
395
+ t_start = torch.as_tensor(t_start, device=device)
396
+ t_end = torch.as_tensor(t_end, device=device)
397
+ t_start, t_end = torch.broadcast_tensors(t_start, t_end)
398
+
399
+ valid = (t_end < cumsum_probs.shape[0]) & (t_start < cumsum_probs.shape[0]) & (t_end > 0) & (t_start >= 0)
400
+ max_vals = torch.zeros_like(t_start, dtype=cumsum_probs.dtype, device=device)
401
+ max_idx = torch.full_like(t_start, -1, dtype=torch.long, device=device)
402
+
403
+ if not valid.any():
404
+ return max_vals, max_idx
405
+
406
+ idx_end = t_end[valid].long()
407
+ idx_start = t_start[valid].long()
408
+ probs_end = cumsum_probs[idx_end] # (k, P)
409
+ probs_start = torch.zeros_like(probs_end)
410
+ nonzero_mask = idx_start > 0
411
+ if nonzero_mask.any():
412
+ probs_start[nonzero_mask] = cumsum_probs[idx_start[nonzero_mask]]
413
+
414
+ segment_sum = probs_end - probs_start # (k, P)
415
+ lengths = (t_end[valid] - t_start[valid]).clamp(min=1).unsqueeze(1).to(segment_sum.dtype)
416
+ segment_mean = segment_sum / lengths # (k, P)
417
+
418
+ vals, idxs = segment_mean.max(dim=1) # per-row max and argmax
419
+ max_vals[valid] = vals
420
+ max_idx[valid] = idxs.long()
421
+ return max_vals, max_idx
422
+
423
+ def get_timit_61_phoneme_mappings():
424
+ """
425
+ Returns the TIMIT 61 phoneme-to-index mapping and the reverse index-to-phoneme mapping.
426
+
427
+ Returns:
428
+ phoneme_to_idx (dict): Dictionary mapping phonemes to unique indices.
429
+ idx_to_phoneme (dict): Dictionary mapping indices to their corresponding phonemes.
430
+ """
431
+ # this is actually including the leehon 39 phonemes!!!!!
432
+ timit_61_phonemes = [
433
+ 'aa', 'ae', 'ah', 'ao', 'aw', 'ax', 'ax-h', 'axr', 'ay',
434
+ 'b', 'bcl', 'ch', 'd', 'dcl', 'dh', 'dx', 'eh', 'el', 'em', 'en', 'eng', 'epi', 'er', 'ey',
435
+ 'f', 'g', 'gcl', 'h#', 'hh', 'hv', 'ih', 'ix', 'iy', 'jh', 'k', 'kcl', 'l', 'm', 'n', 'ng',
436
+ 'nx', 'ow', 'oy', 'p', 'pau', 'pcl', 'q', 'r', 's', 'sh', 't', 'tcl', 'th', 'uh', 'uw', 'ux',
437
+ 'v', 'w', 'y', 'z', 'zh'
438
+ ]
439
+ timit_leehon_39_phonemes = [
440
+ 'ao', 'ae', 'ah','aw', 'er', 'ay',
441
+ 'b', 'sil', 'ch', 'd', 'dh', 'dx', 'eh', 'el', 'm', 'en', 'ng', 'ey',
442
+ 'f', 'g', 'hh', 'ih', 'iy', 'jh', 'k', 'v', 'w', 'y', 'z', 'sh', 't', 'r', 's', 'th','uh', 'uw', 'oy', 'ow','p'
443
+ ]
444
+ # Create mappings
445
+ phoneme_to_idx = {phoneme: idx for idx, phoneme in enumerate(timit_leehon_39_phonemes)}
446
+ idx_to_phoneme = {idx: phoneme for phoneme, idx in phoneme_to_idx.items()}
447
+
448
+ return phoneme_to_idx, idx_to_phoneme
449
+
450
+ # --------------------------------
451
+
452
+
453
+ def timit_to_leehon(timit_label):
454
+ # Mapping of TIMIT 61 phonemes to Leehon 39 phonemes
455
+ timit_to_leehon_map = {
456
+ 'aa': 'ao', 'ae': 'ae', 'ah': 'ah', 'ao': 'ao', 'aw': 'aw', 'ax': 'ah', 'ax-h': 'ah', 'axr': 'er', 'ay': 'ay',
457
+ 'b': 'b', 'bcl': 'sil', 'ch': 'ch', 'd': 'd', 'dcl': 'sil', 'dh': 'dh', 'dx': 'dx', 'eh': 'eh', 'el': 'el',
458
+ 'em': 'm', 'en': 'en', 'eng': 'ng', 'epi': 'sil', 'er': 'er', 'ey': 'ey', 'f': 'f', 'g': 'g', 'gcl': 'sil',
459
+ 'h#': 'sil', 'hh': 'hh', 'hv': 'hh', 'ih': 'ih', 'ix': 'ih', 'iy': 'iy', 'jh': 'jh', 'k': 'k', 'kcl': 'sil',
460
+ 'l': 'el', 'm': 'm', 'n': 'en', 'ng': 'ng', 'nx': 'en', 'ow': 'ow', 'oy': 'oy', 'p': 'p', 'pau': 'sil', 'pcl': 'sil',
461
+ 'q': 't', 'qcl': 'sil', 'r': 'r', 's': 's', 'sh': 'sh', 't': 't', 'tcl': 'sil', 'th': 'th', 'uh': 'uh', 'uw': 'uw',
462
+ 'ux': 'uw', 'v': 'v', 'w': 'w', 'y': 'y', 'z': 'z', 'zh': 'sh', '':'sil'
463
+ }
464
+
465
+ # Return the corresponding Leehon 39 label, or None if the label is not found
466
+ return timit_to_leehon_map.get(timit_label.lower(), None)
467
+
468
+ def load_phoneme_stats():
469
+ phonemes_path = "phonemes_39"
470
+ stats_path = "phoneme_stats_39.out"
471
+
472
+ # Load phoneme names
473
+ with open(phonemes_path, "r") as f:
474
+ phonemes = [line.strip() for line in f]
475
+
476
+ # Load mu values (second row of stats file)
477
+ with open(stats_path, "r") as f:
478
+ lines = f.readlines()
479
+ mu_values = list(map(float, lines[1].strip().split())) # Convert to float
480
+ sigma_values = list(map(float, lines[2].strip().split())) # Convert to float
481
+
482
+ # Create phoneme-to-mu dictionary
483
+ phoneme_mu_dict = dict(zip(phonemes, mu_values))
484
+ phoneme_sigma_dict = dict(zip(phonemes, sigma_values))
485
+
486
+ return phoneme_mu_dict, phoneme_sigma_dict
487
+
488
+ # Load phoneme stats once
489
+
490
+
491
+ def get_mu_stats(p):
492
+ phoneme_mu_dict, _ = load_phoneme_stats()
493
+ """Return the mu value for the given phoneme p."""
494
+ return phoneme_mu_dict.get(p, None) # Return None if phoneme is not found
495
+
496
+ def get_sigma_stats(p):
497
+ _, phoneme_sigma_dict = load_phoneme_stats()
498
+ """Return the mu value for the given phoneme p."""
499
+ return phoneme_sigma_dict.get(p, None) # Return None if phoneme is not found
500
+
501
+ def replicate_first_k_frames(x, k, dim):
502
+ return torch.cat([x.index_select(dim=dim, index=torch.LongTensor([0] * k).to(x.device)), x], dim=dim)
503
+
504
+ class LambdaLayer(nn.Module):
505
+ def __init__(self, lambd):
506
+ super(LambdaLayer, self).__init__()
507
+ self.lambd = lambd
508
+ def forward(self, x):
509
+ return self.lambd(x)
510
+
511
+ class PrintShapeLayer(nn.Module):
512
+ def __init__(self):
513
+ super(PrintShapeLayer, self).__init__()
514
+ def forward(self, x):
515
+ print(x.shape)
516
+ return x
517
+
518
+ def length_to_mask(length, max_len=None, dtype=None):
519
+ """length: B.
520
+ return B x max_len.
521
+ If max_len is None, then max of length will be used.
522
+ """
523
+ assert len(length.shape) == 1, 'Length shape should be 1 dimensional.'
524
+ max_len = max_len or length.max().item()
525
+ mask = torch.arange(max_len, device=length.device,
526
+ dtype=length.dtype).expand(len(length), max_len) < length.unsqueeze(1)
527
+ if dtype is not None:
528
+ mask = torch.as_tensor(mask, dtype=dtype, device=length.device)
529
+ return mask
530
+
531
+ def detect_peaks_worker(xi,w_phi, p_seq, original_lengths, probs_real, len_ratio, width, distance):
532
+ print(f"num peaks = {len(p_seq)}")
533
+ print(f"xi type: {type(xi)}")
534
+ preds_np = xi.requires_grad_(True)
535
+ median_h = preds_np.median()
536
+ preds_np = preds_np - median_h
537
+ derivative_preds_np = preds_np
538
+ xmin, xmax = xi.min(), xi.max()
539
+ xi = (xi - xmin) / (xmax - xmin)
540
+ xi = xi.flatten()
541
+
542
+ peaks = phoneme_alignment(p_seq,w_phi, original_lengths, len_ratio, derivative_preds_np, probs_real)
543
+
544
+ if len(peaks) == 0:
545
+ peaks = torch.tensor([xi.shape[0] - 1], device=xi.device)
546
+
547
+ return peaks
548
+
549
+ def detect_peaks(x,w_phi, original_lengths_all, phonemes, len_ratio, probs_real_all):
550
+ """Detect peaks of next_frame_classifier using multithreading."""
551
+
552
+ out = []
553
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
554
+ xi=x
555
+ p_seq = phonemes
556
+ original_lengths = original_lengths_all
557
+ probs_real = probs_real_all
558
+ if len(xi)!=0:
559
+ result = detect_peaks_worker(xi, w_phi, p_seq, [original_lengths], probs_real, len_ratio, width=None, distance=None)
560
+ out.append(result)
561
+
562
+ return out
563
+
564
+ class PrecisionRecallMetric:
565
+ def __init__(self):
566
+ self.precision_counter = 0
567
+ self.recall_counter = 0
568
+ self.pred_counter = 0
569
+ self.gt_counter = 0
570
+ self.eps = 1e-5
571
+ self.data = []
572
+ self.tolerance = 2
573
+ self.width_range = [1]
574
+ self.distance_range = [1]
575
+
576
+ def get_metrics(self, precision_counter, recall_counter, pred_counter, gt_counter):
577
+ EPS = 1e-7
578
+
579
+ precision = precision_counter / (pred_counter + self.eps)
580
+ recall = recall_counter / (gt_counter + self.eps)
581
+ f1 = 2 * (precision * recall) / (precision + recall + self.eps)
582
+
583
+ os = recall / (precision + EPS) - 1
584
+ r1 = np.sqrt((1 - recall) ** 2 + os ** 2)
585
+ r2 = (-os + recall - 1) / (np.sqrt(2))
586
+ rval = 1 - (np.abs(r1) + np.abs(r2)) / 2
587
+
588
+ return precision, recall, f1, rval
589
+
590
+ def zero(self):
591
+ self.data = []
592
+
593
+ def update(self, seg, pos_pred, length,original_lengths_all, probs_all,phonemes_all):
594
+ for seg_i, pos_pred_i, length_i , original_length, probs,phonemes in zip(seg, pos_pred, length,original_lengths_all,probs_all,phonemes_all):
595
+ self.data.append((seg_i, pos_pred_i, length_i.item(),[original_length.item()], probs, phonemes))
596
+
597
+
598
+ def get_stats(self, width=None, distance=None):
599
+ print(f"calculating metrics using {len(self.data)} entries")
600
+ max_rval = -float("inf")
601
+ min_l1_dist = float("inf")
602
+ best_params = None
603
+ segs = list(map(lambda x: x[0], self.data))
604
+ length = list(map(lambda x: x[2], self.data))
605
+ yhats = list(map(lambda x: x[1], self.data))
606
+ original_lengths_all = list(map(lambda x: x[3], self.data))
607
+ probs = list(map(lambda x: x[4], self.data))
608
+ phonemes = list(map(lambda x: x[5], self.data))
609
+
610
+ width_range = self.width_range
611
+ distance_range = self.distance_range
612
+
613
+ if width is not None:
614
+ width_range = [width]
615
+ distance_range = [distance]
616
+ sr = 16000
617
+ len_ratio = 161.34011627906978
618
+
619
+ for width in width_range:
620
+ for distance in distance_range:
621
+ for (y, yhat,original_len, phoneme, prob) in zip(segs, yhats, original_lengths_all, phonemes, probs):
622
+ if isinstance(y,list):
623
+ y = torch.tensor(y, device=yhat.device, dtype=yhat.dtype)
624
+ peaks = detect_peaks(x=yhat,w_phi= [0.5,0.5],
625
+ original_lengths_all = original_len[0],
626
+ phonemes = phoneme,
627
+ len_ratio = 161.34011627906978 ,
628
+ probs_real_all = prob)
629
+ peaks = peaks[0]* len_ratio/sr
630
+ yhat = peaks
631
+ yhat = yhat[1:]
632
+
633
+ if isinstance(y,list):
634
+ y = torch.tensor(y, device=yhat.device, dtype=yhat.dtype)
635
+ y = y*len_ratio/sr
636
+ l1_dist = torch.mean(torch.abs(y - yhat)).item()
637
+ l2_dist = torch.mean((y - yhat)**2).item()
638
+ if l1_dist<min_l1_dist:
639
+ min_l1_dist = l1_dist
640
+ out = (l1_dist,l2_dist)
641
+ best_params = width, distance
642
+ self.zero()
643
+ print(f"best peak detection params: {best_params} (width, distance)")
644
+ print(f"best peak detection L1_DIST: {l1_dist}")
645
+ print(f"best peak detection L2_DIST: {l2_dist}")
646
+ return out, best_params
647
+
648
+
649
+ class StatsMeter:
650
+ def __init__(self):
651
+ self.data = []
652
+
653
+ def update(self, item):
654
+ if type(item) == list:
655
+ self.data.extend(item)
656
+ else:
657
+ self.data.append(item)
658
+
659
+ def get_stats(self):
660
+ data = np.array(self.data)
661
+ if len(data)==0:
662
+ return float('nan')
663
+ mean = data.mean()
664
+ return mean
665
+
666
+ def zero(self):
667
+ self.data.clear()
668
+ assert len(self.data) == 0, "StatsMeter didn't clear"
669
+
670
+
671
+ class Timer:
672
+ def __init__(self, msg):
673
+ self.msg = msg
674
+ self.start_time = None
675
+
676
+ def __enter__(self):
677
+ self.start_time = time.time()
678
+ print(f"{self.msg} -- started")
679
+
680
+ def __exit__(self, exc_type, exc_value, exc_tb):
681
+ print(f"{self.msg} -- done in {(time.time() - self.start_time)} secs")
682
+
683
+
684
+ def max_min_norm(x):
685
+ x -= x.min(-1, keepdim=True)[0]
686
+ x /= x.max(-1, keepdim=True)[0]
687
+ return x
688
+
689
+
690
+ def line():
691
+ print(90 * "-")
word_g2p.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stand-alone word -> Lee-Hon-39 phoneme front-end for *word-level* alignment.
3
+
4
+ This converts orthographic words into a phoneme sequence so the (otherwise
5
+ phoneme-level) aligner can run on word input. It is deliberately independent
6
+ of MFA / any user-supplied pronunciation dictionary or acoustic model:
7
+
8
+ word --espeak (grapheme->IPA, rule-based, multilingual)--> IPA
9
+ IPA --panphon ipa_segs--> IPA segments
10
+ seg --dutch_preprocess.find_best_leehon39 (articulatory distance)--> LH39
11
+
12
+ The IPA->LH39 step is the *same* panphon mapping already used on the phoneme
13
+ path, so word-level and phoneme-level share one LH39 back-end. Only the word
14
+ case routes through here; the phoneme path is unchanged.
15
+
16
+ espeak is a stand-alone open-source phonemizer (not MFA), so nothing here
17
+ depends on the system we compare against at runtime.
18
+ """
19
+ import os
20
+ import subprocess
21
+
22
+ import panphon
23
+
24
+ import dutch_preprocess
25
+
26
+ # espeak voice used for word-level G2P. "en-us" = American English (TIMIT).
27
+ # Override per-language via the FDNFA_G2P_VOICE env var (e.g. "nl", "de", "he").
28
+ DEFAULT_VOICE = os.environ.get("FDNFA_G2P_VOICE", "en-us")
29
+
30
+ # Insert TIMIT-style closure/silence segments so the word-derived phoneme
31
+ # sequence matches the model's training granularity. Closures/silences are ~22%
32
+ # of TIMIT reference segments (all map to LH39 'sil') and no text G2P emits
33
+ # them; restoring them via this parameter-free rule recovers most of the
34
+ # word-vs-phoneme gap (+~16 pts @25ms). Disable with FDNFA_WORD_CLOSURES=0.
35
+ USE_CLOSURES = os.environ.get("FDNFA_WORD_CLOSURES", "1").lower() not in ("0", "false", "no")
36
+ STOPS = {"b", "d", "g", "p", "t", "k"}
37
+
38
+ _ft = panphon.FeatureTable()
39
+ _cache = {} # (word_lower, voice) -> [lh39, ...]
40
+ # espeak decorates IPA with stress / length / syllable marks that are not
41
+ # phonemes; strip them before segmentation.
42
+ _STRIP = dict.fromkeys(map(ord, "ˈˌːˑ.‿|"), None)
43
+
44
+
45
+ def _with_closures(seq):
46
+ """Insert a 'sil' (closure) before every stop, mirroring TIMIT segmentation."""
47
+ out = []
48
+ for p in seq:
49
+ if p in STOPS:
50
+ out.append("sil")
51
+ out.append(p)
52
+ return out
53
+
54
+
55
+ def _espeak_ipa(word, voice):
56
+ try:
57
+ out = subprocess.run(
58
+ ["espeak", "-q", "--ipa", "-v", voice, word],
59
+ capture_output=True, text=True, timeout=10,
60
+ ).stdout
61
+ except Exception as exc: # espeak missing / failed -> caller handles empty
62
+ print(f"[word_g2p] espeak failed for {word!r}: {exc}")
63
+ return ""
64
+ return out.strip().translate(_STRIP)
65
+
66
+
67
+ # Word-level G2P backend: "espeak" (default, multilingual, MFA-independent) or
68
+ # "mfa" (the english_us_arpa Pynini G2P that Montreal Forced Aligner uses — for
69
+ # apples-to-apples English word-level comparison). Override via FDNFA_WORD_G2P.
70
+ G2P_BACKEND = os.environ.get("FDNFA_WORD_G2P", "espeak").lower()
71
+
72
+
73
+ def word_to_lh39(word, voice=None, backend=None):
74
+ """Orthographic word -> list of LH39 phonemes (cached).
75
+
76
+ Routes through the espeak or MFA-english_us_arpa G2P. `backend`/`voice`
77
+ default to the FDNFA_WORD_G2P / FDNFA_G2P_VOICE env vars when not passed, so a
78
+ long-running process (e.g. the Gradio app) can switch per call by argument.
79
+ The MFA backend applies to English (it uses the english_us_arpa dictionary).
80
+ """
81
+ if backend is None:
82
+ backend = os.environ.get("FDNFA_WORD_G2P", "espeak").lower()
83
+ if voice is None:
84
+ voice = os.environ.get("FDNFA_G2P_VOICE", DEFAULT_VOICE)
85
+ if backend == "mfa":
86
+ import mfa_g2p # lazy: mfa_g2p imports closure helpers from this module
87
+ return mfa_g2p.word_to_lh39_mfa(word, voice=voice)
88
+ if backend == "char":
89
+ return _char_word_lh39(word)
90
+ return _espeak_word_lh39(word, voice)
91
+
92
+
93
+ def _char_word_lh39(word):
94
+ """Word -> LH39 with NO G2P model: segment the (romanized) characters with
95
+ panphon and map each directly to LH39 by articulatory-feature distance. Used
96
+ for languages with no MFA model and where a grapheme-to-phoneme converter is
97
+ deliberately avoided (e.g. Hebrew romanized transcripts)."""
98
+ key = (word.lower(), "char")
99
+ if key in _cache:
100
+ return _cache[key]
101
+ segs = _ft.ipa_segs(word.lower())
102
+ lh39 = [dutch_preprocess.find_best_leehon39(s)[0] for s in segs if s.strip()]
103
+ if not lh39:
104
+ lh39 = ["sil"]
105
+ if USE_CLOSURES:
106
+ lh39 = _with_closures(lh39)
107
+ _cache[key] = lh39
108
+ return lh39
109
+
110
+
111
+ def _espeak_word_lh39(word, voice=DEFAULT_VOICE):
112
+ """Word -> LH39 via espeak (cached). The espeak branch of word_to_lh39, also
113
+ used by the MFA backend as the OOV fallback for languages with no MFA G2P."""
114
+ key = (word.lower(), voice)
115
+ if key in _cache:
116
+ return _cache[key]
117
+ ipa = _espeak_ipa(word, voice)
118
+ segs = _ft.ipa_segs(ipa) if ipa else []
119
+ lh39 = [dutch_preprocess.find_best_leehon39(s)[0] for s in segs if s.strip()]
120
+ if not lh39:
121
+ lh39 = ["sil"]
122
+ if USE_CLOSURES:
123
+ lh39 = _with_closures(lh39)
124
+ _cache[key] = lh39
125
+ return lh39