Files
UmbraAudio/backup/author_voice_lab/scripts/probe_formant.py
2026-10-18 19:01:04 -07:00

73 lines
2.8 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Probe 3: prove the missing primitive is an INDEPENDENT FORMANT SHIFTER.
pitch_shift_pv is a phase vocoder -> it moves F0 and the spectral envelope by
DIFFERENT amounts (measured: formants lag pitch going up, overshoot going down).
A vocal-tract (formant) shift is the missing primitive and is achievable with
resample + time_stretch, which decouples the two.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, "/Volumes/1TB_APL_Storage/UmbraAudio")
import librosa
import numpy as np
import soundfile as sf
from umbraaudio.generation.audio_effects import EFFECT_FN
SRC = Path("/private/var/folders/y8/cbvhds297918lrxt2wd3btsr0000gn/T/opencode/probe_out/A_baseline.wav")
OUT = Path("/private/var/folders/y8/cbvhds297918lrxt2wd3btsr0000gn/T/opencode/probe_out")
_pv = EFFECT_FN["pitch_shift_pv"]
def m(y, sr):
y = y.astype(np.float32)
f0, _, _ = librosa.pyin(y, fmin=60.0, fmax=400.0, sr=sr)
v = f0[~np.isnan(f0)]
S = np.abs(librosa.stft(y, n_fft=2048, hop_length=512))
freqs = librosa.fft_frequencies(sr=sr, n_fft=2048)
msk = freqs <= 4000
ftr = float((S[msk] * freqs[msk][:, None]).sum() / S[msk].sum())
return float(np.median(v)), ftr
def formant_shift(y, sr, semitones: float) -> np.ndarray:
"""Shift ONLY the vocal-tract envelope, leaving F0 and duration untouched."""
r = 2.0 ** (semitones / 12.0)
up = librosa.resample(y.astype(np.float32), orig_sr=sr, target_sr=int(round(sr * r)))
back = librosa.resample(up, orig_sr=int(round(sr * r)), target_sr=sr)
return librosa.effects.time_stretch(back.astype(np.float32), rate=r).astype(np.float32)
y, sr = sf.read(str(SRC), dtype="float32")
b_f0, b_ftr = m(y, sr)
print(f"BASE F0={b_f0:6.1f}Hz LTAS4k={b_ftr:6.0f}Hz\n")
print("Formant shift ALONE (F0 must stay put, LTAS4k must move):")
print(f"{'f_st':>6} {'F0':>8} {'dF0_st':>8} {'LTAS4k':>8} {'dftr_st':>8}")
for fs in (-6, -3, 3, 6):
o = formant_shift(y, sr, fs)
f0, ftr = m(o, sr)
print(f"{fs:>6} {f0:>8.1f} {12*np.log2(f0/b_f0):>8.1f} {ftr:>8.0f} {12*np.log2(ftr/b_ftr):>8.1f}")
sf.write(OUT / f"formant_{fs:+d}.wav", o, sr)
print("\nCOMBINED male->female recipe: pitch +10 st THEN formant +3 st")
o = formant_shift(_pv(y, sr, semitones=10.0), sr, 3.0)
f0, ftr = m(o, sr)
print(f" F0={f0:.1f}Hz ({12*np.log2(f0/b_f0):+.1f} st) LTAS4k={ftr:.0f}Hz "
f"({12*np.log2(ftr/b_ftr):+.1f} st)")
sf.write(OUT / "female_recipe.wav", o, sr)
print("\nCOMBINED male->dragon recipe: pitch -10 st THEN formant -8 st (vocal-tract growth)")
o = formant_shift(_pv(y, sr, semitones=-10.0), sr, -8.0)
f0, ftr = m(o, sr)
print(f" F0={f0:.1f}Hz ({12*np.log2(f0/b_f0):+.1f} st) LTAS4k={ftr:.0f}Hz "
f"({12*np.log2(ftr/b_ftr):+.1f} st)")
sf.write(OUT / "dragon_recipe.wav", o, sr)