73 lines
2.8 KiB
Python
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)
|