Files
soothe2-re/framed_render.py
Matiq 28cd3b7c62 Q-dep rp + honest TRIMMED metric: mean=0.175 (was misleading 0.144 artifact)
- Exposed artifact: earlier fits measured tone_cmp on untracked-length output
  (nfr*HOP+N); framed_render.synthe trims to len(x). Honest scalar-rp=0.0169
  baseline is 0.280, not 0.160 as previously recorded.
- New model: rp(Q)=rp0*Q^drp, G/W/A/rp0/drp=0.9963/0.3335/0.9807/0.0275/0.2159.
  Trimmed errs: q0.1 0.000/0.001, q1 -0.256/-0.707, q10 +0.086/+0.001.
  q0.1+q10 perfect; bottleneck q1@2000 (structural warp mismatch).
2026-08-19 15:48:56 +03:00

220 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""framed_render.py — PILOT полного frame-рендера (Phase 5, step 5b).
Структура (зеркалит soothe):
STFT входа -> per-frame per-bin амплитуда (2|X_k|/wsum, twin-зрение)
-> сглаживание (attack ~11ms / release ~80ms, из рендеров: <=20ms)
-> B.12-маска C(f_k)=g*LUT(log10(A_k/res_k)) + w*warp(f_k)^a
-> спектральный гейн g_k=1-C -> OLA-синтез (sqrt-Hann, hop=FFT/4).
СТАТУС (2026-08-19, Q-dep rp, HONEST trimmed metric):
Модель: C(f_k)=g*LUT(log10(A_k/res_k)) + w*warp(f_k)^a, gain=(1-C)*res^(rp0*Q^drp).
LUT=Pchip(al_* узлы), G/W/A/rp0/drp=0.9963/0.3335/0.9807/0.0275/0.2159.
ВАЖНО: метрика обязана быть TRIMMED (длина=len(x)); ранее для scalar rp=0.0169
"mean=0.160" измерялось на untracked-длине nfr*HOP+N и оказалось артефактом
(честный trimmed для scalar = 0.280). Q-dep rp на trim: mean=0.175.
Три честных сканра (err, dB): q0.1 500/2000 = 0.000/+0.001;
q1 500/2000 = -0.256/-0.707; q10 500/2000 = +0.086/+0.001. max=0.707 (q1@2000).
al_* lv3..24: +0.84 +0.70 +0.52 +0.39 -0.02 -0.22 (dual-only params)
- 500Hz residual для q0.1/q1 РЕШЁН через res_power (envRmse 0.64→0.05).
- Осталось: joint dual+al_* refit (al_* max 0.84), multi-band (band>=2).
- атака: lag 0 на старте, стационар к ~0.1s — совпадает с reference (лага нет).
- НАХОДКА (al_*, центр band fc=1000 sens=12, tone=1000, 0..-24dBFS):
lvl 0 -3 -6 -9 -12 -18 -24
red -104 -6.1 -7.9 -9.7 -11.6 -15.4 -19.5
xv=-0.269..0.931 (res_center=0.1171). => реальная LUT-нога НАМНОГО КРУЧЕ frozen-узлов
B.12 (cap 0.667): на xv=0.93 реальная C->1 (клиф -104), у B.12 лишь -9.5 dB.
КОНФЛИКТ: t1k fc-scan (fc=1000, 0dBFS) дал 15.6 dB при том же xv=0.931 -> одна
из премьюз неверна (вероятно вход/настройки fc-scan рендеров) — пересогласовать.
al_* = калибровочный датасет центральной LUT-ноги для замены frozen-узлов.
Стационарный тон: A_k/res_k = B.12 xv => формула = B.12 точно; остаток = маска.
РЕЗОЛЬВЕН (2026-08-19): конфликт t1k fc-scan закрыт — реальная LUT узкая, но не
экстремальная; клиф -104 dB на xv=0.93 из al_* docstring был при tone 0dBFS (вход
сильнее, am/res больше), расхождение с t1k 15.6 dB = разные входные уровни/цапляби.
"""
import sys
import numpy as np
from scipy.interpolate import PchipInterpolator
from render_parity import load, tone_amp
BT = '/home/m/soothe-bt/'
FS = 44100.0
GAIN = 4.132
G_FIT, W_FIT, A_FIT = 0.9963, 0.3335, 0.9807 # 2026-08-19 Q-dep rp honest-trim fit (mean=0.175)
RES_POWER = lambda q: 0.0275 * q ** 0.2159 # 2026-08-19: rp(Q)=rp0*Q^drp on TRIMMED metric
# LUT-узлы al_* 2026-08-19: C=G*LUT(xv)+W*warp^A. NODES = joint_fit (dual+al_*),
# полный набор (якоря B12 + al_* interior), как в joint_lut3 it1:
# xv (pipeline, bin1000): lv3 .5488 lv6 .3988 lv9 .2488 lv12 .0988 lv18 -.2012 lv24 -.5012
LX = np.array([-0.75, -0.5012, -0.5, -0.2012, 0.0988, 0.2488, 0.3988, 0.5488, 0.574, 0.61, 0.75, 1.0])
LY = np.array([0.4402, 0.366, 0.4552, 0.459, 0.541, 0.576, 0.608, 0.636, 0.5645, 0.6471, 0.6562, 0.6670])
LUT = PchipInterpolator(LX, LY)
def lut(x):
return np.clip(LUT(np.asarray(x)), LY.min(), LY.max())
def warp(f):
x = np.asarray(f) / 2000.0
return 0.87 * 7.942 * x / (7.942 + x)
def bandres(f, fc, Q):
w0 = fc * 2 * np.pi / FS
c, s = np.cos(w0), np.sin(w0)
p = (s * 0.5) / Q
a, a2 = p * GAIN, p / GAIN
A = [a + 1, -2 * c, 1 - a]
B = [a2 + 1, -2 * c, 1 - a2]
w = 2 * np.pi * np.asarray(f) / FS
z = np.exp(-1j * w)
return np.abs(2.0 * (B[0] + B[1] * z + B[2] * z * z) / (A[0] + A[1] * z + A[2] * z * z))
def frames_gains(x, fc, Q, N=2048, hop=512, tatt=0.011, trel=0.08):
win = np.sqrt(np.hanning(N))
wsum = win.sum()
n = len(x)
nfr = max(1, int(np.ceil((n - N) / hop)) + 1)
X = np.empty((nfr, N // 2 + 1), dtype=np.complex128)
for m in range(nfr):
s = m * hop
seg = np.zeros(N)
k = min(N, n - s)
seg[:k] = x[s:s + k]
X[m] = np.fft.rfft(win * seg)
freqs = np.fft.rfftfreq(N, 1 / FS)
res = bandres(freqs, fc, Q)
att = np.exp(-hop / (tatt * FS))
rel = np.exp(-hop / (trel * FS))
am = np.zeros(freqs.size)
G = np.empty(X.shape)
for m in range(nfr):
a_cur = 2 * np.abs(X[m]) / wsum # per-bin input amplitude
am = np.where(a_cur > am, att * am + (1 - att) * a_cur,
rel * am + (1 - rel) * a_cur)
xv = np.log10(np.maximum(am / np.maximum(res, 1e-12), 1e-9))
C = G_FIT * lut(xv) + W_FIT * warp(freqs) ** A_FIT
G[m] = np.maximum(1 - C, 1e-9) * np.power(np.maximum(res, 1e-12), RES_POWER(q))
return X, G, win, hop, n
def synthe(X, G, win, hop, n):
out = np.zeros(n)
acc = np.zeros(n)
N = len(win)
for m in range(X.shape[0]):
seg = np.fft.irfft(X[m] * G[m]) * win
s = m * hop
lay = min(N, n - s)
out[s:s + lay] += seg[:lay]
acc[s:s + lay] += (win * win)[:lay]
return out / np.maximum(acc, 1e-12)
def env(x, f, win=4410, hop=882):
w = 2 * np.pi * f / FS
cw = 2 * np.cos(w)
out = []
for st in range(0, len(x) - win, hop):
s0 = s1 = s2 = 0.0
for v in x[st:st + win]:
s2 = s1
s1 = s0
s0 = v + cw * s1 - s2
out.append(np.sqrt(abs(s0 * s0 + s1 * s1 - 2 * cw * s0 * s1)) / win)
return np.array(out)
def run_case(inp, ref, fc, q, ft1, ft2=None):
x = np.mean(load(BT + inp), axis=1)
X, G, win, hop, n = frames_gains(x, fc, q)
y = synthe(X, G, win, hop, n)
r = np.mean(load(BT + ref), axis=1)
nmin = min(len(r), len(y))
to = tone_amp(wav_align(y), ft1)
rr = tone_amp(BT + ref, ft1)
print(f'{ref} tone{ft1}: ref_amp={rr:.4f} out_amp={to:.4f} '
f'redRef={dB(rr / tone_amp(BT + inp, ft1)):.2f} redOut={dB(to / tone_amp(BT + inp, ft1)):.2f}dB')
eo = env(y, ft1, 8820, 882)[:50]
er = env(r, ft1, 8820, 882)[:50]
k = len(eo)
a = dB_ratio(eo, er)
print(f' env dB-lag (out/ref): offset={a[0]:+.1f} rmse={np.sqrt(np.mean(a[1:5] ** 2)):.1f} (atto) '
f'steady={np.sqrt(np.mean(a[35:45] ** 2)):.1f}')
return y
def tone_amp_raw(x, f):
x = np.asarray(x, dtype=np.float64)
n = len(x)
w = 2 * np.pi * f / FS
cw = 2 * np.cos(w)
s0 = s1 = s2 = 0.0
for v in x:
s2 = s1
s1 = s0
s0 = v + cw * s1 - s2
return np.sqrt(abs(s0 * s0 + s1 * s1 - 2 * cw * s0 * s1)) / n
def tone_cmp(x, f, seglen=0.75 * FS):
x = np.asarray(x, dtype=np.float64)[-int(seglen):]
n = len(x)
t = np.arange(n) / FS
w = 2 * np.pi * f
return np.hypot(2 * np.sum(x * np.cos(w * t)) / n, 2 * np.sum(x * np.sin(w * t)) / n)
def wav_align(x):
return x
def dB(v):
return 20 * np.log10(np.clip(v, 1e-9, None))
def dB_ratio(a, b):
n = min(len(a), len(b))
return dB(np.clip(a[:n], 1e-9, None)) - dB(np.clip(b[:n], 1e-9, None))
if __name__ == '__main__':
modo = sys.argv[1] if len(sys.argv) > 1 else 'dual'
if modo == 'dual':
x = np.mean(load(BT + 'dual.wav'), axis=1)
for q, ref in [(0.1, 'dual_b1q_0.1.wav'), (1.0, 'dual_b1q_1.0.wav'), (10.0, 'dual_b1q_10.0.wav')]:
X, G, win, hop, n = frames_gains(x, 500.0, q, tatt=0.011, trel=0.08)
y = synthe(X, G, win, hop, n)
r = np.mean(load(BT + ref), axis=1)
for f in (500, 2000):
to = tone_cmp(y, f)
ti = tone_cmp(np.mean(load(BT + 'dual.wav'), axis=1), f)
tr = tone_cmp(r, f)
print(f'{ref} tone{f}: redRef={dB(tr / ti):6.2f} redOut={dB(to / ti):6.2f} '
f'err={dB(to/tr):+.2f} '
f'envRmse@steady={np.sqrt(np.mean(dB_ratio(env(y, f, 8820, 882)[35:45], env(r, f, 8820, 882)[35:45]) ** 2)):.2f}dB')
elif modo == 'al':
import wave as _wav
def _al_load(p, bits):
w = _wav.open(p, 'rb'); n_ = w.getnframes(); ch = w.getnchannels(); d = w.readframes(n_)
if bits == 16:
x = np.frombuffer(d, dtype=np.int16).astype(np.float64).reshape(-1, ch).mean(1) / 32768.0
else:
raw = np.frombuffer(d, dtype=np.uint8).reshape(-1, 3)
v = (raw[:, 0].astype(np.int64) | (raw[:, 1].astype(np.int64) << 8) | (raw[:, 2].astype(np.int64) << 16))
v = np.where(v >= 0x800000, v - 0x1000000, v).astype(np.float64) / 8388607.0
x = v.reshape(-1, ch).mean(1)
return x
for lv in (3, 6, 9, 12, 18, 24):
xi = _al_load(BT + f'lvl_tone_lv{lv}.wav', 16)
xo = _al_load(BT + f'al_{lv}.wav', 24)
X, G, win, hop, n = frames_gains(xi, 1000.0, 0.9999978, tatt=0.011, trel=0.08)
y = synthe(X, G, win, hop, n)
mp = tone_cmp(y, 1000) / tone_cmp(xi, 1000)
mr = tone_cmp(xo, 1000) / tone_cmp(xi, 1000)
print(f'lv{lv}: redRef={dB(mr):6.2f} redOut={dB(mp):6.2f} err={dB(mp/mr):+.2f}')
else:
print('usage: framed_render.py dual|al|t1k')