95 lines
3.3 KiB
Python
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")
|