From f8b91e80159861d46608eb6bac666206260ba8de Mon Sep 17 00:00:00 2001 From: Matiq Date: Wed, 19 Aug 2026 22:00:14 +0300 Subject: [PATCH] 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 --- .gitignore | 1 + dsp/harness.cpp | 164 ++++++++++++++++------------ dsp/params.hpp | 38 +++++++ handoff/rpp_allparams.py | 18 ++- verify_bit_exact.py | 230 ++++++++++++++++++++++++--------------- 5 files changed, 287 insertions(+), 164 deletions(-) create mode 100644 dsp/params.hpp diff --git a/.gitignore b/.gitignore index fcade05..d48c397 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ !**/*.npz !**/*.java !handoff/ +!dsp/ !**/*.cpp !**/*.hpp !**/*.c diff --git a/dsp/harness.cpp b/dsp/harness.cpp index f96b897..a4ae922 100644 --- a/dsp/harness.cpp +++ b/dsp/harness.cpp @@ -1,91 +1,68 @@ #include #include #include +#include #include #include #include +#include #include "spectral.hpp" #include "filter.hpp" #include "detect.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& out) { std::ifstream f(path, std::ios::binary); if (!f) return -1; - char riff[12]; f.read(riff, 12); - if (riff[0] != 'R' || riff[1] != 'I' || riff[2] != 'F' || riff[3] != 'F') return -1; - if (riff[8] != 'W' || riff[9] != 'A' || riff[10] != 'V' || riff[11] != 'E') return -1; - + if (memcmp(riff, "RIFF", 4) || memcmp(riff + 8, "WAVE", 4)) return -1; while (true) { char chunk_id[4]; - f.read(chunk_id, 4); - if (!f.good()) return -1; - uint32_t chunk_size; - f.read(reinterpret_cast(&chunk_size), 4); - if (!f.good()) return -1; - - if (chunk_id[0] == 'f' && chunk_id[1] == 'm' && chunk_id[2] == 't' && chunk_id[3] == ' ') { + if (!f.read(chunk_id, 4) || !f.read(reinterpret_cast(&chunk_size), 4)) return -1; + if (memcmp(chunk_id, "fmt ", 4) == 0) { if (chunk_size < 16) return -1; - - int16_t audio_fmt, bits; - uint16_t channels, block_align; + uint16_t audio_fmt, channels, block_align, bits; uint32_t sample_rate, bytes_per_sec; - f.read(reinterpret_cast(&audio_fmt), 2); f.read(reinterpret_cast(&channels), 2); f.read(reinterpret_cast(&sample_rate), 4); f.read(reinterpret_cast(&bytes_per_sec), 4); f.read(reinterpret_cast(&block_align), 2); f.read(reinterpret_cast(&bits), 2); - if (chunk_size > 16) f.seekg(chunk_size - 16, std::ios::cur); - while (true) { - char data_id[4]; - f.read(data_id, 4); - if (!f.good()) return -1; - - uint32_t data_size; - f.read(reinterpret_cast(&data_size), 4); - if (!f.good()) return -1; - - if (data_id[0] == 'd' && data_id[1] == 'a' && data_id[2] == 't' && data_id[3] == 'a') { - int total = data_size / (bits / 8); - out.resize(total); - std::vector raw(total); - f.read(reinterpret_cast(raw.data()), data_size); - for (int i = 0; i < total; i++) { - out[i] = static_cast(raw[i]) / 32768.0f; - } + char id[4]; + uint32_t dsize; + if (!f.read(id, 4) || !f.read(reinterpret_cast(&dsize), 4)) return -1; + if (memcmp(id, "data", 4) == 0) { + size_t n = dsize / (bits / 8); + out.resize(n); + std::vector raw(n); + f.read(reinterpret_cast(raw.data()), dsize); + for (size_t i = 0; i < n; i++) out[i] = static_cast(raw[i]) / 32768.0f; return static_cast(sample_rate); } else { - f.seekg(data_size, std::ios::cur); + f.seekg(dsize, std::ios::cur); } } - break; } else { 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); - - int bits = 24; - 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 block_align = channels * 3; + int data_size = static_cast(samples) * channels * 3; int file_size = 36 + data_size; + f.write("RIFF", 4); f.write(reinterpret_cast(&file_size), 4); f.write("WAVE", 4); - f.write("fmt ", 4); int fmt_size = 16; f.write(reinterpret_cast(&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(&audio_fmt), 2); f.write(reinterpret_cast(&channels), 2); f.write(reinterpret_cast(&sample_rate), 4); + int bytes_per_sec = sample_rate * block_align; f.write(reinterpret_cast(&bytes_per_sec), 4); f.write(reinterpret_cast(&block_align), 2); + int16_t bits = 24; f.write(reinterpret_cast(&bits), 2); - f.write("data", 4); f.write(reinterpret_cast(&data_size), 4); - - for (int i = 0; i < samples * channels; i++) { + for (size_t i = 0; i < samples * static_cast(channels); i++) { float val = std::max(-1.0f, std::min(1.0f, data[i])); int32_t ival = static_cast(val * 8388607.0f); - unsigned char bytes[3]; - bytes[0] = ival & 0xff; - bytes[1] = (ival >> 8) & 0xff; - bytes[2] = (ival >> 16) & 0xff; + unsigned char bytes[3] = { static_cast(ival & 0xff), + static_cast((ival >> 8) & 0xff), + static_cast((ival >> 16) & 0xff) }; f.write(reinterpret_cast(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[]) { 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; } - + PluginParams params; + if (argc > 3) params = parse_params_file(argv[3]); + else { + params.bands.push_back(BandParams{}); + } + std::vector input; float sr = read_wav16(argv[1], input); if (sr <= 0 || input.empty()) { - std::cerr << "Failed to read input file" << std::endl; + std::cerr << "Failed to read input file\n"; return 1; } - - size_t total_samples = input.size(); - int channels = 2; - size_t frames = total_samples / channels; - - std::vector output(total_samples); - - SpectralProcessor sp(2048, 512); - sp.setDetectorParams(10.0f, 10.0f, 0.864f); - + int channels = 2; // all etalon renders are 2ch + size_t frames = input.size() / static_cast(channels); + // Trim guard: output length == input length (honest metric, B.14). + if (input.size() % channels != 0) frames = input.size() / channels; + std::vector left_in(frames), right_in(frames); for (size_t i = 0; i < frames; i++) { left_in[i] = input[i * 2]; right_in[i] = input[i * 2 + 1]; } - + + SpectralProcessor sp(2048, 512); + sp.setDetectorParams( + static_cast(params.sharpness), + static_cast(params.selectivity), + static_cast(params.depth)); + std::vector left(frames, 0.0f), right(frames, 0.0f); encode_ms(left_in.data(), right_in.data(), frames); sp.processBlock(left_in.data(), left.data(), frames, 1); sp.processBlock(right_in.data(), right.data(), frames, 1); decode_ms(left.data(), right.data(), frames); - + + std::vector output(frames * 2); for (size_t i = 0; i < frames; i++) { output[i * 2] = left[i]; output[i * 2 + 1] = right[i]; } - write_wav24(argv[2], output.data(), frames, channels, static_cast(sr)); - - std::cout << "Done!" << std::endl; - + std::cout << "Done! frames=" << frames << " sr=" << sr << "\n"; return 0; -} +} \ No newline at end of file diff --git a/dsp/params.hpp b/dsp/params.hpp new file mode 100644 index 0000000..aefc4a5 --- /dev/null +++ b/dsp/params.hpp @@ -0,0 +1,38 @@ +#pragma once +// Plugin parameters decoded from RPP XML (see handoff/rpp_allparams.py). +#include +#include +#include + +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 bands; // up to 6 +}; \ No newline at end of file diff --git a/handoff/rpp_allparams.py b/handoff/rpp_allparams.py index 746a09c..03aa515 100644 --- a/handoff/rpp_allparams.py +++ b/handoff/rpp_allparams.py @@ -64,9 +64,15 @@ def params_json(path): return p if __name__ == '__main__': - import json - 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]}') \ No newline at end of file + 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:]: + p = params_json(f) + print('===', f.split('/')[-1]) + for k in sorted(p): + print(f' {k:22s} = {p[k]}') \ No newline at end of file diff --git a/verify_bit_exact.py b/verify_bit_exact.py index 32fdc58..aeae936 100755 --- a/verify_bit_exact.py +++ b/verify_bit_exact.py @@ -1,108 +1,166 @@ #!/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 +`.wav -> /tmp/out_.wav` via dsp/harness, compare samples against +`TEST_ROOT/_ref.wav` (plugin output). Reports samples differing, max |dx|, +RMSE and first mismatching sample. +""" import subprocess +import sys import os -import glob import wave 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" +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): - """Read WAV24 file and return numpy array or list of int24 samples.""" +def read_wav3(path): with wave.open(path, 'rb') as w: n = w.getnframes() - data = w.readframes(n * w.getnchannels()) - sw = w.getsampwidth() nc = w.getnchannels() - - if sw == 3: - samples = [] - for i in range(0, len(data), 3): - val = struct.unpack('= 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): - """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 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 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 +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: - print(f"FAIL {name}: {diff} byte differences") - return False + rmse = 0.0 + 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(): - rpps = sorted(glob.glob(f"{TEST_ROOT}/*.rpp")) - print(f"Found {len(rpps)} RPP files") - - passed = 0 - failed = 0 - 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 + args = [a for a in sys.argv[1:] if not a.startswith('--')] + mono = '--mono' in sys.argv[1:] + single = args[0] if args else None + if single: + names = [single] + else: + 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) + + for name in names: + src, ref, conf = rpp_meta(name) + if not os.path.exists(src) if src else True: + stat['skip'] += 1 continue - - result = test_sweep(name) - if result is True: - passed += 1 - elif result is False: - failed += 1 + 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: - skipped += 1 - - print(f"\nSummary: {passed} passed, {failed} failed, {skipped} skipped") + 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__": - main() + main() \ No newline at end of file