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
+87 -67
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,45 +70,84 @@ 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++) {
@@ -139,20 +155,24 @@ int main(int argc, char* argv[]) {
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
};
+7 -1
View File
@@ -64,7 +64,13 @@ def params_json(path):
return p return p
if __name__ == '__main__': if __name__ == '__main__':
import json import json, sys
if len(sys.argv) > 2 and sys.argv[1] == '--flat':
for f in sys.argv[2:]:
p = params_json(f)
for k in sorted(p):
print(f'{k}={p[k]}')
else:
for f in sys.argv[1:]: for f in sys.argv[1:]:
p = params_json(f) p = params_json(f)
print('===', f.split('/')[-1]) print('===', f.split('/')[-1])
+145 -87
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()
extra = w.readframes(n)
if sw == 3: if sw == 3:
samples = [] raw = bytearray(extra)
for i in range(0, len(data), 3): # make int list
val = struct.unpack('<i', data[i:i+3] + b'\x00')[0] if len(raw) % 3:
if val & 0x800000: raw += b'\x00' * (3 - len(raw) % 3)
val -= 0x1000000 vals = []
samples.append(val) for i in range(0, len(raw), 3):
return samples, nc v = raw[i] | (raw[i+1] << 8) | (raw[i+2] << 16)
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 rpp_meta(name):
"""Return (input_path, ref_path, conf_path) from RPP project file."""
rpp = f"{TEST_ROOT}/{name}.rpp"
if not os.path.exists(rpp):
return None, None, None
src = ref = None
in_src = False
with open(rpp, errors='replace') as f:
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 first_mismatch(a, b, lim=50000):
for i in range(min(len(a), len(b), lim)):
if a[i] != b[i]:
return i
return -1
def compare(name, in_wav, mono, report=3):
src, ref, conf = rpp_meta(name)
if not conf:
return None, "params decode failed"
if not src or not os.path.exists(src):
return None, f"source wav missing: {src}"
if not ref or not os.path.exists(ref):
return None, f"ref wav missing: {ref}"
out = f"/tmp/out_{name}.wav"
cmd = [HARNESS, in_wav, out, conf]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if r.returncode != 0:
return None, f"harness rc={r.returncode}: {r.stderr[:200]}"
ra, rc, rw = read_wav3(ref)
oa, oc, ow = read_wav3(out)
if not ra or not oa:
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:
return list(struct.unpack(f'<{len(data)//2}h', data)), nc rmse = 0.0
mm = first_mismatch(ra, oa)
def run_harness(in_wav, out_wav): return dict(frames=n, diff=diff, frac=diff / n if n else 1.0,
"""Run harness.cpp renderer.""" mx=mx, rmse=rmse, first=mm), None
cmd = [RENDERER, in_wav, out_wav]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
return result.returncode == 0
except Exception as e:
print(f"Error running {in_wav}: {e}")
return False
def compare_bytes(path1, path2):
"""Compare two WAV files byte-by-byte. Returns (match, diff_count, max_diff)."""
with wave.open(path1, 'rb') as w1, wave.open(path2, 'rb') as w2:
if w1.getparams() != w2.getparams():
return False, -1, -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):
"""Test single sweep: in.wav -> render -> out.wav, compare to ref.wav."""
in_wav = f"{TEST_ROOT}/{name}.wav"
ref_wav = f"{TEST_ROOT}/{name}_ref.wav"
out_wav = f"/tmp/out_{name}.wav"
if not os.path.exists(in_wav):
print(f"SKIP {name}: no input")
return None
if not run_harness(in_wav, out_wav):
print(f"FAIL {name}: renderer error")
return False
match, diff, max_diff = compare_bytes(ref_wav, out_wav)
if match:
print(f"PASS {name}")
return True
else:
print(f"FAIL {name}: {diff} byte differences")
return False
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
for rpp in rpps[:10]: # Test first 10
name = os.path.splitext(os.path.basename(rpp))[0]
wav = f"{TEST_ROOT}/{name}.wav"
if not os.path.exists(wav):
skipped += 1
continue
result = test_sweep(name)
if result is True:
passed += 1
elif result is False:
failed += 1
else: else:
skipped += 1 names = sorted(os.path.splitext(os.path.basename(p))[0]
for p in glob.glob(f"{TEST_ROOT}/*.rpp"))
print(f"{len(names)} cases ('--mono' = single channel)")
stat = dict(bit=0, nearbit=0, diff=0, skip=0)
print(f"\nSummary: {passed} passed, {failed} failed, {skipped} skipped") for name in names:
src, ref, conf = rpp_meta(name)
if not os.path.exists(src) if src else True:
stat['skip'] += 1
continue
res, err = compare(name, src, mono)
if res is None:
print(f" SKIP {name}: {err}")
stat['skip'] += 1
continue
frac = res['frac']
if frac == 0.0:
stat['bit'] += 1
tag = "BIT-EXACT"
elif frac < 0.05:
stat['nearbit'] += 1
tag = "near"
else:
stat['diff'] += 1
tag = "DIFF"
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()