mamba-cot-tts / code /scripts /contour_metrics.py
maxpicy's picture
Add files using upload-large-folder tool
6e802a4 verified
Raw History Blame Contribute Delete
3.71 kB
# contour_metrics.py: F0 contour diagnostics used by the suite.
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO / "scripts"))
def _f0_semitones(a, sr):
from score_naturalness_panel import f0_track
f0, fs = f0_track(a, sr)
f0 = np.asarray(f0, dtype=float)
ok = np.isfinite(f0) & (f0 > 0)
if ok.sum() < 12:
return None, None, None
idx = np.arange(len(f0))
filled = np.interp(idx, idx[ok], f0[ok])
st = 12.0 * np.log2(filled / np.median(f0[ok]))
return st, float(fs), ok
def contour_metrics(a, sr) -> dict:
nan = float("nan")
st, fs, ok = _f0_semitones(a, sr)
if st is None or len(st) < 16:
return {"phrase": nan, "stress": nan, "fast": nan, "prosodic": nan,
"f0e_corr": nan, "decl": nan}
x = st - st.mean()
win = np.hanning(len(x))
P = np.abs(np.fft.rfft(x * win)) ** 2
f = np.fft.rfftfreq(len(x), 1.0 / fs)
band = lambda lo, hi: float(P[(f >= lo) & (f < hi)].sum())
tot = band(0.2, 20.0) + 1e-12
phrase, stress, fast = band(0.2, 1.5), band(1.5, 4.0), band(4.0, 20.0)
hop = max(1, int(round(sr / fs)))
n = min(len(st), len(a) // hop)
rms = np.sqrt(np.mean(a[:n * hop].reshape(n, hop) ** 2, axis=1)) + 1e-9
db = 20.0 * np.log10(rms)
m = ok[:n] if ok is not None else np.ones(n, bool)
corr = nan
if m.sum() >= 12:
u, v = st[:n][m], db[m]
if u.std() > 1e-6 and v.std() > 1e-6:
corr = float(np.corrcoef(u, v)[0, 1])
t = np.arange(len(st)) / fs
decl = float(np.polyfit(t, st, 1)[0]) if len(st) > 4 else nan
return {"phrase": 100 * phrase / tot, "stress": 100 * stress / tot,
"fast": 100 * fast / tot, "prosodic": 100 * (phrase + stress) / tot,
"f0e_corr": corr, "decl": decl}
def contour_similarity(a_gen, sr_gen, a_ref, sr_ref) -> float:
g, _, _ = _f0_semitones(a_gen, sr_gen)
r, _, _ = _f0_semitones(a_ref, sr_ref)
if g is None or r is None:
return float("nan")
n = min(len(g), len(r), 400)
if n < 16:
return float("nan")
gi = np.interp(np.linspace(0, 1, n), np.linspace(0, 1, len(g)), g)
ri = np.interp(np.linspace(0, 1, n), np.linspace(0, 1, len(r)), r)
if gi.std() < 1e-6 or ri.std() < 1e-6:
return float("nan")
return float(np.corrcoef(gi, ri)[0, 1])
if __name__ == "__main__":
import argparse
import soundfile as sf
ap = argparse.ArgumentParser()
ap.add_argument("dirs", nargs="+", help="wav dirs to compare")
ap.add_argument("--limit", type=int, default=None)
args = ap.parse_args()
print(f"{'source':<32} {'n':>3} {'phrase%':>8} {'stress%':>8} {'fast%':>7} "
f"{'PROSODIC%':>10} {'f0xLoud':>8} {'decl':>7}")
print("-" * 90)
for d in args.dirs:
rows = []
for w in sorted(Path(d).glob("*.wav"))[:args.limit]:
a, sr = sf.read(str(w))
if a.ndim > 1:
a = a.mean(axis=1)
rows.append(contour_metrics(a, sr))
if not rows:
print(f"{Path(d).name:<32} (no wavs)")
continue
mean = lambda k: float(np.nanmean([r[k] for r in rows]))
print(f"{Path(d).name:<32} {len(rows):>3} {mean('phrase'):>8.1f} "
f"{mean('stress'):>8.1f} {mean('fast'):>7.1f} {mean('prosodic'):>10.1f} "
f"{mean('f0e_corr'):>8.2f} {mean('decl'):>7.2f}")
print("\nprosodic% = pitch motion that is phrase/stress structured (higher = "
"more expressive intent).\nf0xLoud = stress coupling; decl = "
"semitones/s drift.")