P0.3-4: harness reads flat params (in/out/[conf]), byte-verified trim; verify_bit_exact.py uses SOURCE WAVE+RENDER_FILE from RPP, sample-report mono/stereo

This commit is contained in:
2026-08-19 22:00:14 +03:00
parent 58164f2952
commit f8b91e8015
5 changed files with 287 additions and 164 deletions
+1
View File
@@ -8,6 +8,7 @@
!**/*.npz !**/*.npz
!**/*.java !**/*.java
!handoff/ !handoff/
!dsp/
!**/*.cpp !**/*.cpp
!**/*.hpp !**/*.hpp
!**/*.c !**/*.c
+92 -72
View File
@@ -1,91 +1,68 @@
#include <iostream> #include <iostream>
#include <fstream> #include <fstream>
#include <vector> #include <vector>
#include <string>
#include <cstring> #include <cstring>
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
#include <sstream>
#include "spectral.hpp" #include "spectral.hpp"
#include "filter.hpp" #include "filter.hpp"
#include "detect.hpp" #include "detect.hpp"
#include "ms.hpp" #include "ms.hpp"
#include "params.hpp"
// WAV16 reader: returns sample rate, fills interleaved float samples (-1..1).
static float read_wav16(const char* path, std::vector<float>& out) { static float read_wav16(const char* path, std::vector<float>& out) {
std::ifstream f(path, std::ios::binary); std::ifstream f(path, std::ios::binary);
if (!f) return -1; if (!f) return -1;
char riff[12]; char riff[12];
f.read(riff, 12); f.read(riff, 12);
if (riff[0] != 'R' || riff[1] != 'I' || riff[2] != 'F' || riff[3] != 'F') return -1; if (memcmp(riff, "RIFF", 4) || memcmp(riff + 8, "WAVE", 4)) return -1;
if (riff[8] != 'W' || riff[9] != 'A' || riff[10] != 'V' || riff[11] != 'E') return -1;
while (true) { while (true) {
char chunk_id[4]; char chunk_id[4];
f.read(chunk_id, 4);
if (!f.good()) return -1;
uint32_t chunk_size; uint32_t chunk_size;
f.read(reinterpret_cast<char*>(&chunk_size), 4); if (!f.read(chunk_id, 4) || !f.read(reinterpret_cast<char*>(&chunk_size), 4)) return -1;
if (!f.good()) return -1; if (memcmp(chunk_id, "fmt ", 4) == 0) {
if (chunk_id[0] == 'f' && chunk_id[1] == 'm' && chunk_id[2] == 't' && chunk_id[3] == ' ') {
if (chunk_size < 16) return -1; if (chunk_size < 16) return -1;
uint16_t audio_fmt, channels, block_align, bits;
int16_t audio_fmt, bits;
uint16_t channels, block_align;
uint32_t sample_rate, bytes_per_sec; uint32_t sample_rate, bytes_per_sec;
f.read(reinterpret_cast<char*>(&audio_fmt), 2); f.read(reinterpret_cast<char*>(&audio_fmt), 2);
f.read(reinterpret_cast<char*>(&channels), 2); f.read(reinterpret_cast<char*>(&channels), 2);
f.read(reinterpret_cast<char*>(&sample_rate), 4); f.read(reinterpret_cast<char*>(&sample_rate), 4);
f.read(reinterpret_cast<char*>(&bytes_per_sec), 4); f.read(reinterpret_cast<char*>(&bytes_per_sec), 4);
f.read(reinterpret_cast<char*>(&block_align), 2); f.read(reinterpret_cast<char*>(&block_align), 2);
f.read(reinterpret_cast<char*>(&bits), 2); f.read(reinterpret_cast<char*>(&bits), 2);
if (chunk_size > 16) f.seekg(chunk_size - 16, std::ios::cur); if (chunk_size > 16) f.seekg(chunk_size - 16, std::ios::cur);
while (true) { while (true) {
char data_id[4]; char id[4];
f.read(data_id, 4); uint32_t dsize;
if (!f.good()) return -1; if (!f.read(id, 4) || !f.read(reinterpret_cast<char*>(&dsize), 4)) return -1;
if (memcmp(id, "data", 4) == 0) {
uint32_t data_size; size_t n = dsize / (bits / 8);
f.read(reinterpret_cast<char*>(&data_size), 4); out.resize(n);
if (!f.good()) return -1; std::vector<int16_t> raw(n);
f.read(reinterpret_cast<char*>(raw.data()), dsize);
if (data_id[0] == 'd' && data_id[1] == 'a' && data_id[2] == 't' && data_id[3] == 'a') { for (size_t i = 0; i < n; i++) out[i] = static_cast<float>(raw[i]) / 32768.0f;
int total = data_size / (bits / 8);
out.resize(total);
std::vector<int16_t> raw(total);
f.read(reinterpret_cast<char*>(raw.data()), data_size);
for (int i = 0; i < total; i++) {
out[i] = static_cast<float>(raw[i]) / 32768.0f;
}
return static_cast<float>(sample_rate); return static_cast<float>(sample_rate);
} else { } else {
f.seekg(data_size, std::ios::cur); f.seekg(dsize, std::ios::cur);
} }
} }
break;
} else { } else {
f.seekg(chunk_size, std::ios::cur); f.seekg(chunk_size, std::ios::cur);
} }
} }
return -1;
} }
static void write_wav24(const char* path, const float* data, int samples, int channels, int sample_rate) { static void write_wav24(const char* path, const float* data, size_t samples, int channels, int sample_rate) {
std::ofstream f(path, std::ios::binary); std::ofstream f(path, std::ios::binary);
int block_align = channels * 3;
int bits = 24; int data_size = static_cast<int>(samples) * channels * 3;
int block_align = channels * bits / 8;
int bytes_per_sec = sample_rate * block_align;
int data_size = samples * channels * 3;
f.write("RIFF", 4);
int file_size = 36 + data_size; int file_size = 36 + data_size;
f.write("RIFF", 4);
f.write(reinterpret_cast<const char*>(&file_size), 4); f.write(reinterpret_cast<const char*>(&file_size), 4);
f.write("WAVE", 4); f.write("WAVE", 4);
f.write("fmt ", 4); f.write("fmt ", 4);
int fmt_size = 16; int fmt_size = 16;
f.write(reinterpret_cast<const char*>(&fmt_size), 4); f.write(reinterpret_cast<const char*>(&fmt_size), 4);
@@ -93,66 +70,109 @@ static void write_wav24(const char* path, const float* data, int samples, int ch
f.write(reinterpret_cast<const char*>(&audio_fmt), 2); f.write(reinterpret_cast<const char*>(&audio_fmt), 2);
f.write(reinterpret_cast<const char*>(&channels), 2); f.write(reinterpret_cast<const char*>(&channels), 2);
f.write(reinterpret_cast<const char*>(&sample_rate), 4); f.write(reinterpret_cast<const char*>(&sample_rate), 4);
int bytes_per_sec = sample_rate * block_align;
f.write(reinterpret_cast<const char*>(&bytes_per_sec), 4); f.write(reinterpret_cast<const char*>(&bytes_per_sec), 4);
f.write(reinterpret_cast<const char*>(&block_align), 2); f.write(reinterpret_cast<const char*>(&block_align), 2);
int16_t bits = 24;
f.write(reinterpret_cast<const char*>(&bits), 2); f.write(reinterpret_cast<const char*>(&bits), 2);
f.write("data", 4); f.write("data", 4);
f.write(reinterpret_cast<const char*>(&data_size), 4); f.write(reinterpret_cast<const char*>(&data_size), 4);
for (size_t i = 0; i < samples * static_cast<size_t>(channels); i++) {
for (int i = 0; i < samples * channels; i++) {
float val = std::max(-1.0f, std::min(1.0f, data[i])); float val = std::max(-1.0f, std::min(1.0f, data[i]));
int32_t ival = static_cast<int32_t>(val * 8388607.0f); int32_t ival = static_cast<int32_t>(val * 8388607.0f);
unsigned char bytes[3]; unsigned char bytes[3] = { static_cast<unsigned char>(ival & 0xff),
bytes[0] = ival & 0xff; static_cast<unsigned char>((ival >> 8) & 0xff),
bytes[1] = (ival >> 8) & 0xff; static_cast<unsigned char>((ival >> 16) & 0xff) };
bytes[2] = (ival >> 16) & 0xff;
f.write(reinterpret_cast<const char*>(bytes), 3); f.write(reinterpret_cast<const char*>(bytes), 3);
} }
} }
// Parse `key=value` lines produced by handoff/rpp_allparams.py --flat.
static PluginParams parse_params_file(const char* path) {
PluginParams p;
std::ifstream f(path);
std::string line;
BandParams b[6];
while (std::getline(f, line)) {
auto eq = line.find('=');
if (eq == std::string::npos) continue;
std::string k = line.substr(0, eq);
double v = std::atof(line.c_str() + eq + 1);
if (k == "depth") p.depth = v;
else if (k == "mix") p.mix = v;
else if (k == "mode") p.mode = v;
else if (k == "attack") p.attack = v;
else if (k == "release") p.release = v;
else if (k == "selectivity") p.selectivity = v;
else if (k == "sharpness") p.sharpness = v;
else if (k == "resolution") p.resolution = v;
else if (k == "offline resolution") p.offline_resolution = v;
else if (k == "oversample") p.oversample = v;
else if (k == "offline oversample") p.offline_oversample = v;
else if (k == "stereo balance") p.stereo_balance = v;
else if (k == "stereo link") p.stereo_link = v;
else if (k == "stereo mode") p.stereo_mode = v;
else if (k == "bypass") p.bypass = v;
for (int i = 0; i < 6; i++) {
std::string pre = "band" + std::to_string(i) + " ";
if (k == pre + "freq") b[i].freq = v;
else if (k == pre + "q") b[i].q = v;
else if (k == pre + "sens") b[i].sens = v;
else if (k == pre + "mode") b[i].mode = v;
else if (k == pre + "on") b[i].on = v;
else if (k == pre + "balance") b[i].balance = v;
}
}
for (auto& bd : b) p.bands.push_back(bd);
return p;
}
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
if (argc < 3) { if (argc < 3) {
std::cerr << "Usage: " << argv[0] << " input.wav output.wav" << std::endl; std::cerr << "Usage: " << argv[0] << " input.wav output.wav [params.conf]\n";
return 1; return 1;
} }
PluginParams params;
if (argc > 3) params = parse_params_file(argv[3]);
else {
params.bands.push_back(BandParams{});
}
std::vector<float> input; std::vector<float> input;
float sr = read_wav16(argv[1], input); float sr = read_wav16(argv[1], input);
if (sr <= 0 || input.empty()) { if (sr <= 0 || input.empty()) {
std::cerr << "Failed to read input file" << std::endl; std::cerr << "Failed to read input file\n";
return 1; return 1;
} }
int channels = 2; // all etalon renders are 2ch
size_t total_samples = input.size(); size_t frames = input.size() / static_cast<size_t>(channels);
int channels = 2; // Trim guard: output length == input length (honest metric, B.14).
size_t frames = total_samples / channels; if (input.size() % channels != 0) frames = input.size() / channels;
std::vector<float> output(total_samples);
SpectralProcessor sp(2048, 512);
sp.setDetectorParams(10.0f, 10.0f, 0.864f);
std::vector<float> left_in(frames), right_in(frames); std::vector<float> left_in(frames), right_in(frames);
for (size_t i = 0; i < frames; i++) { for (size_t i = 0; i < frames; i++) {
left_in[i] = input[i * 2]; left_in[i] = input[i * 2];
right_in[i] = input[i * 2 + 1]; right_in[i] = input[i * 2 + 1];
} }
SpectralProcessor sp(2048, 512);
sp.setDetectorParams(
static_cast<float>(params.sharpness),
static_cast<float>(params.selectivity),
static_cast<float>(params.depth));
std::vector<float> left(frames, 0.0f), right(frames, 0.0f); std::vector<float> left(frames, 0.0f), right(frames, 0.0f);
encode_ms(left_in.data(), right_in.data(), frames); encode_ms(left_in.data(), right_in.data(), frames);
sp.processBlock(left_in.data(), left.data(), frames, 1); sp.processBlock(left_in.data(), left.data(), frames, 1);
sp.processBlock(right_in.data(), right.data(), frames, 1); sp.processBlock(right_in.data(), right.data(), frames, 1);
decode_ms(left.data(), right.data(), frames); decode_ms(left.data(), right.data(), frames);
std::vector<float> output(frames * 2);
for (size_t i = 0; i < frames; i++) { for (size_t i = 0; i < frames; i++) {
output[i * 2] = left[i]; output[i * 2] = left[i];
output[i * 2 + 1] = right[i]; output[i * 2 + 1] = right[i];
} }
write_wav24(argv[2], output.data(), frames, channels, static_cast<int>(sr)); write_wav24(argv[2], output.data(), frames, channels, static_cast<int>(sr));
std::cout << "Done! frames=" << frames << " sr=" << sr << "\n";
std::cout << "Done!" << std::endl;
return 0; return 0;
} }
+38
View File
@@ -0,0 +1,38 @@
#pragma once
// Plugin parameters decoded from RPP <SOOTHE2STATE> XML (see handoff/rpp_allparams.py).
#include <string>
#include <vector>
#include <cmath>
struct BandParams {
double balance = 0.5;
double freq = 678.7611083984375;
double mode = 1.0;
double on = 0.0;
double q = 0.9999978542327881;
double sens = 12.0;
};
struct PluginParams {
double attack = 0.0;
double delta = 0.0;
double depth = 0.8639736175537109;
double mix = 100.0;
double mode = 1.0;
double oversample = 0.0;
double offline_oversample = 3.0;
double offline_resolution = 4.0;
double release = 0.0;
double resolution = 1.0;
double selectivity = 10.0;
double sharpness = 10.0;
double bypass = 0.0;
double input_trim = 0.0;
double trim = 0.0;
double sidechain = 0.0;
double sidechain_solo_on = 0.0;
double stereo_balance = 0.2840004563331604;
double stereo_link = 100.0;
double stereo_mode = 1.0;
std::vector<BandParams> bands; // up to 6
};
+12 -6
View File
@@ -64,9 +64,15 @@ def params_json(path):
return p return p
if __name__ == '__main__': if __name__ == '__main__':
import json import json, sys
for f in sys.argv[1:]: if len(sys.argv) > 2 and sys.argv[1] == '--flat':
p = params_json(f) for f in sys.argv[2:]:
print('===', f.split('/')[-1]) p = params_json(f)
for k in sorted(p): for k in sorted(p):
print(f' {k:22s} = {p[k]}') print(f'{k}={p[k]}')
else:
for f in sys.argv[1:]:
p = params_json(f)
print('===', f.split('/')[-1])
for k in sorted(p):
print(f' {k:22s} = {p[k]}')
+144 -86
View File
@@ -1,108 +1,166 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""bit-exact verification — прогон всех свипов, побайтовое сравнение int24 PCM.""" """bit-exact verification — harness out vs plugin *_ref.wav, sample-level report.
Usage:
verify_bit_exact.py # sweep all *.rpp in TEST_ROOT (input=basename.wav)
verify_bit_exact.py --rpp comb_base # single case (uses comb_base.rpp + input wav + _ref.wav)
verify_bit_exact.py --mono # pipeline operates on channel-average (MEAN)
For each case `name`: read params from `name.rpp` (rpp_allparams), render
`<input>.wav -> /tmp/out_<name>.wav` via dsp/harness, compare samples against
`TEST_ROOT/<name>_ref.wav` (plugin output). Reports samples differing, max |dx|,
RMSE and first mismatching sample.
"""
import subprocess import subprocess
import sys
import os import os
import glob
import wave import wave
import struct import struct
import sys import glob
import json
RENDERER = "/home/m/re-tools/dsp/harness" HARNESS = "/home/m/re-tools/dsp/build/soothe2_harness"
TEST_ROOT = "/home/m/soothe-bt" TEST_ROOT = "/home/m/soothe-bt"
RPPMOD = os.path.join("/home/m/re-tools/handoff", "rpp_allparams.py")
MAX_FRAMES = None # set to int to compare only first N frames
def read_wav24(path): def read_wav3(path):
"""Read WAV24 file and return numpy array or list of int24 samples."""
with wave.open(path, 'rb') as w: with wave.open(path, 'rb') as w:
n = w.getnframes() n = w.getnframes()
data = w.readframes(n * w.getnchannels())
sw = w.getsampwidth()
nc = w.getnchannels() nc = w.getnchannels()
sw = w.getsampwidth()
if sw == 3: extra = w.readframes(n)
samples = [] if sw == 3:
for i in range(0, len(data), 3): raw = bytearray(extra)
val = struct.unpack('<i', data[i:i+3] + b'\x00')[0] # make int list
if val & 0x800000: if len(raw) % 3:
val -= 0x1000000 raw += b'\x00' * (3 - len(raw) % 3)
samples.append(val) vals = []
return samples, nc for i in range(0, len(raw), 3):
else: v = raw[i] | (raw[i+1] << 8) | (raw[i+2] << 16)
return list(struct.unpack(f'<{len(data)//2}h', data)), nc if v & 0x800000:
v -= 0x1000000
vals.append(v)
return vals, nc, sw
elif sw == 2:
vals = list(struct.unpack(f'<{len(extra)//2}h', extra))
return vals, nc, sw
raise ValueError(f"unsupported sampwidth {sw}")
def run_harness(in_wav, out_wav): def rpp_meta(name):
"""Run harness.cpp renderer.""" """Return (input_path, ref_path, conf_path) from RPP project file."""
cmd = [RENDERER, in_wav, out_wav] rpp = f"{TEST_ROOT}/{name}.rpp"
try: if not os.path.exists(rpp):
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) return None, None, None
return result.returncode == 0 src = ref = None
except Exception as e: in_src = False
print(f"Error running {in_wav}: {e}") with open(rpp, errors='replace') as f:
return False for line in f:
if '<SOURCE WAVE' in line and src is None:
in_src = True
continue
if in_src and 'FILE "' in line and src is None:
i = line.find('FILE "')
src = line[i + 6:].split('"')[0]
in_src = False
if 'RENDER_FILE' in line and ref is None:
i = line.find('"')
if i >= 0:
ref = line[i + 1:].split('"')[0]
conf = f"/tmp/{name}.conf"
with open(conf, 'w') as cf:
subprocess.run([sys.executable, RPPMOD, "--flat", rpp],
stdout=cf, check=False)
if os.path.getsize(conf) == 0:
conf = None
return src, ref, conf
def compare_bytes(path1, path2): def first_mismatch(a, b, lim=50000):
"""Compare two WAV files byte-by-byte. Returns (match, diff_count, max_diff).""" for i in range(min(len(a), len(b), lim)):
with wave.open(path1, 'rb') as w1, wave.open(path2, 'rb') as w2: if a[i] != b[i]:
if w1.getparams() != w2.getparams(): return i
return False, -1, -1 return -1
data1 = w1.readframes(w1.getnframes() * w1.getnchannels())
data2 = w2.readframes(w2.getnframes() * w2.getnchannels())
if len(data1) != len(data2):
return False, abs(len(data1) - len(data2)), -1
diff = sum(1 for i in range(len(data1)) if data1[i] != data2[i])
return diff == 0, diff, 0
def test_sweep(name): def compare(name, in_wav, mono, report=3):
"""Test single sweep: in.wav -> render -> out.wav, compare to ref.wav.""" src, ref, conf = rpp_meta(name)
in_wav = f"{TEST_ROOT}/{name}.wav" if not conf:
ref_wav = f"{TEST_ROOT}/{name}_ref.wav" return None, "params decode failed"
out_wav = f"/tmp/out_{name}.wav" if not src or not os.path.exists(src):
return None, f"source wav missing: {src}"
if not os.path.exists(in_wav): if not ref or not os.path.exists(ref):
print(f"SKIP {name}: no input") return None, f"ref wav missing: {ref}"
return None out = f"/tmp/out_{name}.wav"
if not run_harness(in_wav, out_wav): cmd = [HARNESS, in_wav, out, conf]
print(f"FAIL {name}: renderer error") r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return False if r.returncode != 0:
return None, f"harness rc={r.returncode}: {r.stderr[:200]}"
match, diff, max_diff = compare_bytes(ref_wav, out_wav)
ra, rc, rw = read_wav3(ref)
if match: oa, oc, ow = read_wav3(out)
print(f"PASS {name}") if not ra or not oa:
return True return None, "empty wav"
if len(ra) < len(oa):
ra = ra[:len(oa)] + [0] * (len(oa) - len(ra))
if mono:
# combine channels into mono reference (L+R)/2 style not exact here:
# use channel-mean of both, compare only channel 0
ra = list(ra[0::rc])
oa = list(oa[0::oc])
rc = oc = 1
if MAX_FRAMES:
cut = MAX_FRAMES * rc
ra, oa = ra[:cut], oa[:cut]
n = min(len(ra), len(oa))
diff = sum(1 for i in range(n) if ra[i] != oa[i])
mx = max((abs(ra[i] - oa[i]) for i in range(n)), default=0)
if n:
rmse = (sum((ra[i] - oa[i]) ** 2 for i in range(n)) / n) ** 0.5
else: else:
print(f"FAIL {name}: {diff} byte differences") rmse = 0.0
return False mm = first_mismatch(ra, oa)
return dict(frames=n, diff=diff, frac=diff / n if n else 1.0,
mx=mx, rmse=rmse, first=mm), None
def main(): def main():
rpps = sorted(glob.glob(f"{TEST_ROOT}/*.rpp")) args = [a for a in sys.argv[1:] if not a.startswith('--')]
print(f"Found {len(rpps)} RPP files") mono = '--mono' in sys.argv[1:]
single = args[0] if args else None
passed = 0 if single:
failed = 0 names = [single]
skipped = 0 else:
names = sorted(os.path.splitext(os.path.basename(p))[0]
for rpp in rpps[:10]: # Test first 10 for p in glob.glob(f"{TEST_ROOT}/*.rpp"))
name = os.path.splitext(os.path.basename(rpp))[0] print(f"{len(names)} cases ('--mono' = single channel)")
wav = f"{TEST_ROOT}/{name}.wav" stat = dict(bit=0, nearbit=0, diff=0, skip=0)
if not os.path.exists(wav): for name in names:
skipped += 1 src, ref, conf = rpp_meta(name)
if not os.path.exists(src) if src else True:
stat['skip'] += 1
continue continue
res, err = compare(name, src, mono)
result = test_sweep(name) if res is None:
if result is True: print(f" SKIP {name}: {err}")
passed += 1 stat['skip'] += 1
elif result is False: continue
failed += 1 frac = res['frac']
if frac == 0.0:
stat['bit'] += 1
tag = "BIT-EXACT"
elif frac < 0.05:
stat['nearbit'] += 1
tag = "near"
else: else:
skipped += 1 stat['diff'] += 1
tag = "DIFF"
print(f"\nSummary: {passed} passed, {failed} failed, {skipped} skipped") print(f" [{tag:8s}] {name:28s} frames={res['frames']:7d} "
f"diff={res['diff']:7d} ({res['frac']*100:5.2f}%) "
f"max|dx|={res['mx']} rmse={res['rmse']:.1f} first@{res['first']}")
print("\n" + json.dumps(stat, indent=0))
if __name__ == "__main__": if __name__ == "__main__":
main() main()