Phase5/step5b: full frame-render pilot works (STFT+per-bin LUT mask+OLA); al_* level-sweep exposes steeper real LUT leg vs B.12 frozen knots; fc-scan premise conflict flagged

This commit is contained in:
2026-08-18 19:43:26 +03:00
parent 3b8095cee7
commit ae380c93c8
2 changed files with 195 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
#!/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-18, пилот): full-pipeline РАБОТАЕТ, динамика совпадает.
- steady dual: q0.1: 500 -9.66/-10.22, 2000 -14.08/-15.22; q1: 2000 -10.94/-10.68;
q10: 2000 -10.66/-10.16 (остатки = структурная маска 0.540658, не пайплайн).
- атака: 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 точно; остаток = маска.
"""
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 = 1.221, 0.358, 3.143
LX = np.array([-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.574, 0.61, 0.75, 1.0])
LY = np.array([0.4402, 0.4552, 0.4813, 0.5072, 0.5329, 0.5332, 0.5645, 0.6471, 0.6562, 0.6670])
LUT = PchipInterpolator(LX, LY)
def lut(x):
return np.clip(LUT(np.asarray(x)), LY[0], LY[-1])
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)
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 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_amp_raw(y, f)
ti = tone_amp(BT + 'dual.wav', f)
tr = tone_amp(BT + ref, f)
print(f'{ref} tone{f}: redRef={dB(tr / ti):6.2f} redOut={dB(to / ti):6.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 == 't1k':
x = np.mean(load(BT + 't1k_ref.wav'), axis=1)
X, G, win, hop, n = frames_gains(x, 1000.0, 0.9999978, tatt=0.011, trel=0.08)
y = synthe(X, G, win, hop, n)
r = np.mean(load(BT + 'b1only_12.wav'), axis=1)
to = tone_amp_raw(y, 1000)
ti = tone_amp(BT + 't1k.wav', 1000)
tr = tone_amp(BT + 'b1only_12.wav', 1000)
print(f'b1only_12 tone1000: redRef={dB(tr / ti):6.2f} redOut={dB(to / ti):6.2f}')
else:
print('usage: framed_render.py dual|t1k')
+10
View File
@@ -279,3 +279,13 @@ getFunctionContaining(0x52ac64) and has the complete per-band loop + FFT-conv).
- floats: {1,0,0,0,-1,0,0,0,0,1,0,0,1,1,0,0} (A) / {-1,0,...} (B) — шаблон блочный, НЕ чистая - floats: {1,0,0,0,-1,0,0,0,0,1,0,0,1,1,0,0} (A) / {-1,0,...} (B) — шаблон блочный, НЕ чистая
attack/decay константа; doubles: 0.0078125=1/128 на 4-й/6-й слотах. => отложить, не декодить. attack/decay константа; doubles: 0.0078125=1/128 на 4-й/6-й слотах. => отложить, не декодить.
- Более информативна ctor-инициализация 0x540880=0x540884=1.0f (movl 0x3f800000, disasm 0x535d19). - Более информативна ctor-инициализация 0x540880=0x540884=1.0f (movl 0x3f800000, disasm 0x535d19).
## ============ 2026-08-18f (STEP5b: FULL FRAME-RENDER PILOT - WORKS) ============
## framed_render.py: STFT(2048/512, sqrt-Hann) -> per-bin amp 2|X|/wsum (att 11ms/rel 80ms)
## -> B.12 mask C=g*LUT(xv)+w*warp^a, gain=1-C -> OLA. VALID: dynamics match, steady ~B.12.
## al_* DATASET (центр band fc=1000 sens12, tone1000, level-свип 0..-24dBFS):
## lvl 0,-3,-6,-9,-12,-18,-24 dBFS -> red -104,-6.1,-7.9,-9.7,-11.6,-15.4,-19.5 dB.
## xv=-0.269..0.931 (res_center=0.1171) => REAL LUT-нога КРУЧЕ frozen-узлов (cap 0.667):
## на xv=0.931 C->1 (клиф), у B.12 только -9.5. КОНФЛИКТ с t1k fc-scan (15.6@0dBFS там же)
## => премьюза fc-scan рендеров неверна (вход/настройки) - ПЕРЕСОГЛАСОВАТЬ.
## al_* = калибровочный датасет для замены LUT-узлов B.11 (structural 0.540658-piece).