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

95 lines
3.3 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Probe: can the narrator vmatrix be morphed (gender / creature) via instruct?
Read-only against the project; writes WAVs + measurements to an off-volume temp dir.
Uses the cached narrator vmatrix (prompt_slug) so voice identity is pinned.
"""
from __future__ import annotations
import io
import json
import time
from pathlib import Path
import librosa
import numpy as np
import soundfile as sf
import httpx
URL = "http://127.0.0.1:8881"
OUT = Path("/private/var/folders/y8/cbvhds297918lrxt2wd3btsr0000gn/T/opencode/probe_out")
OUT.mkdir(parents=True, exist_ok=True)
TEXT = (
"The lantern guttered as she pushed the door open, and the cold air of the "
"cavern rolled over her like the breath of something waiting."
)
def measure(wav: np.ndarray, sr: int) -> dict:
y = wav.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))
cen = float(librosa.feature.spectral_centroid(S=S, sr=sr).mean())
flat = float(librosa.feature.spectral_flatness(S=S).mean())
# long-term average spectrum slope: brightness/tilth
rms = float(20 * np.log10(np.sqrt((y**2).mean()) + 1e-12))
if len(v):
out = {
"f0_median": round(float(np.median(v)), 1),
"f0_sd": round(float(v.std()), 1),
"f0_range": [round(float(v.min())), round(float(v.max()))],
"semitones_vs_132hz": round(12 * np.log2(np.median(v) / 132.4), 1),
}
else:
out = {"f0_median": None, "f0_sd": None, "f0_range": None, "semitones_vs_132hz": None}
out.update(
centroid_hz=round(cen),
flatness=round(flat, 5),
rms_db=round(rms, 1),
dur_s=round(len(y) / sr, 2),
)
return out
REF = Path("/Volumes/1TB_APL_Storage/UmbraAudio/.umbra/voices/narrator_ref.wav")
def gen(label: str, *, instruct: str | None, seed: int = 1234) -> dict:
data = {"text": TEXT, "sample_rate": 24000, "temperature": 0.7, "do_sample": True,
"seed": seed}
files = {"ref_audio": ("ref.wav", REF.read_bytes(), "audio/wav")}
if instruct:
data["voice_description"] = instruct
t0 = time.time()
r = httpx.post(f"{URL}/v1/tts", data=data, files=files, timeout=900)
dt = time.time() - t0
if r.status_code != 200:
return {"label": label, "error": f"HTTP {r.status_code}: {r.text[:200]}"}
wav, sr = sf.read(io.BytesIO(r.content), dtype="float32")
if wav.ndim > 1:
wav = wav.mean(axis=1)
sf.write(OUT / f"{label}.wav", wav, sr)
m = measure(wav, sr)
m.update(label=label, seconds=round(dt, 1), instruct=instruct)
print(json.dumps(m), flush=True)
return m
CASES = [
("A_baseline", None),
("B_female", "A young woman's voice, bright and light, with a higher and clearer tone."),
("C_elderly_m", "An elderly man's voice, very deep, gravelly and slow."),
("D_dragon", "A huge ancient dragon's voice: a deep, rumbling, growling monster with "
"a metallic edge and a resonant, echoing chest tone."),
]
if __name__ == "__main__":
results = [gen(label, instruct=instr) for label, instr in CASES]
(OUT / "probe_results.json").write_text(json.dumps(results, indent=2))
print("WROTE", OUT / "probe_results.json")