Deploy FALCON demo (app + bundled MFA G2P assets + example inputs)
Browse files- .gitattributes +2 -35
- README.md +25 -7
- __pycache__/app.cpython-38.pyc +0 -0
- __pycache__/dataloader.cpython-38.pyc +0 -0
- __pycache__/dutch_preprocess.cpython-38.pyc +0 -0
- __pycache__/mfa_g2p.cpython-38.pyc +0 -0
- __pycache__/next_frame_classifier.cpython-38.pyc +0 -0
- __pycache__/predict.cpython-38.pyc +0 -0
- __pycache__/utils.cpython-38.pyc +0 -0
- __pycache__/word_g2p.cpython-38.pyc +0 -0
- app.py +528 -0
- assets/fasw0sa2.phn +36 -0
- assets/fasw0sa2.txt +1 -0
- assets/fasw0sa2.wav +3 -0
- assets/fasw0sa2.wrd +10 -0
- dataloader.py +141 -0
- dutch_preprocess.py +197 -0
- falcon_viz.py +417 -0
- mfa_assets/extracted_models/g2p/english_us_arpa_g2p/model.fst +3 -0
- mfa_assets/extracted_models/g2p/english_us_arpa_g2p/phones.sym +89 -0
- mfa_assets/pretrained_models/dictionary/dutch_cv.dict +0 -0
- mfa_assets/pretrained_models/dictionary/english_us_arpa.dict +0 -0
- mfa_assets/pretrained_models/dictionary/german_mfa.dict +0 -0
- mfa_g2p.py +199 -0
- next_frame_classifier.py +467 -0
- packages.txt +2 -0
- predict.py +293 -0
- requirements.txt +22 -0
- utils.py +691 -0
- word_g2p.py +125 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,2 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.
|
| 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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
|