P0: cat torch/torchvision khoi engine bundle (1842MB -> 147MB) + hop dong API + golden conformance + baseline
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""P0 golden conformance harness (Python oracle) - SonicForgeStudio.
|
||||
|
||||
Muc dich: sinh manifest hash cho cac ham DSP thuan (deterministic) lam CHUAN
|
||||
DOI CHIEU khi port sang Rust (P1-P3). Chay:
|
||||
|
||||
python tools/golden_conformance.py --write # ghi manifest
|
||||
python tools/golden_conformance.py --check # so voi manifest da ghi
|
||||
|
||||
Moi case tra ve bytes canonical (so thuc lam tron 6 chu so thap phan) -> sha256.
|
||||
Cung input (fixture sinh bang seed co dinh trong code) + cung thuat toan =>
|
||||
cung hash; lech => in ro case nao.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
MANIFEST = os.path.join(ROOT, "md", "api_contract", "golden", "manifest.json")
|
||||
SR = 48000
|
||||
|
||||
|
||||
# ── fixtures (seed co dinh, khong dung file ngoai) ───────────────────────────
|
||||
|
||||
def _tone(freq, dur, sr=SR, amp=0.5):
|
||||
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
|
||||
return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
|
||||
|
||||
|
||||
def _fixture_stereo():
|
||||
rng = np.random.default_rng(1234)
|
||||
n = SR * 2
|
||||
t = np.arange(n) / SR
|
||||
left = (0.4 * np.sin(2 * np.pi * 220 * t) + 0.05 * rng.standard_normal(n)).astype(np.float32)
|
||||
right = (0.4 * np.sin(2 * np.pi * 330 * t + 0.7) + 0.05 * rng.standard_normal(n)).astype(np.float32)
|
||||
return np.column_stack([left, right]).astype(np.float32)
|
||||
|
||||
|
||||
def _fixture_click_track():
|
||||
"""Click track 120 BPM de analyzer/beat khong bi suy bien."""
|
||||
n = SR * 4
|
||||
y = np.zeros(n, dtype=np.float32)
|
||||
step = SR // 2 # 120 BPM
|
||||
for i in range(0, n, step):
|
||||
y[i:i + 64] = 0.8
|
||||
return y
|
||||
|
||||
|
||||
def _write_wav(dirpath, name, data, sr=SR):
|
||||
import soundfile as sf
|
||||
p = os.path.join(dirpath, name)
|
||||
sf.write(p, data, sr, subtype="FLOAT")
|
||||
return p
|
||||
|
||||
|
||||
# ── canonical hoa + hash ─────────────────────────────────────────────────────
|
||||
|
||||
def _canon(obj):
|
||||
if isinstance(obj, np.ndarray):
|
||||
a = np.asarray(obj, dtype=np.float64)
|
||||
return json.dumps({"dtype": str(obj.dtype), "shape": list(a.shape),
|
||||
"data": np.round(a.ravel(), 6).tolist()},
|
||||
sort_keys=True)
|
||||
if isinstance(obj, dict):
|
||||
return json.dumps({k: _canon(v) for k, v in sorted(obj.items())}, sort_keys=True)
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return json.dumps([_canon(v) for v in obj])
|
||||
if isinstance(obj, (np.floating, float)):
|
||||
return json.dumps(round(float(obj), 6))
|
||||
if isinstance(obj, (np.integer, int)):
|
||||
return json.dumps(int(obj))
|
||||
if isinstance(obj, bytes):
|
||||
return json.dumps(list(obj))
|
||||
return json.dumps(obj, sort_keys=True)
|
||||
|
||||
|
||||
def _sha(obj):
|
||||
return hashlib.sha256(_canon(obj).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ── cac case ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def collect():
|
||||
cases = {}
|
||||
import soundfile as sf
|
||||
tmp = tempfile.mkdtemp(prefix="sf_golden_")
|
||||
stereo = _fixture_stereo()
|
||||
mono = _tone(440.0, 1.0)
|
||||
p_stereo = _write_wav(tmp, "stereo.wav", stereo)
|
||||
p_mono = _write_wav(tmp, "mono.wav", mono)
|
||||
p_click = _write_wav(tmp, "click.wav", _fixture_click_track())
|
||||
|
||||
from app.core import dsp_utils, audio_editor, analyzer, loudness
|
||||
|
||||
# dsp_utils
|
||||
targets = [0.0, 0.123, 0.5, 1.37, 1.999]
|
||||
cases["dsp_utils.find_zero_crossing"] = [
|
||||
dsp_utils.find_zero_crossing(stereo[:, 0], SR, tt) for tt in targets]
|
||||
|
||||
orig = stereo[:, 0].copy()
|
||||
edited = np.roll(orig, 37)
|
||||
cases["dsp_utils.apply_micro_crossfade"] = dsp_utils.apply_micro_crossfade(
|
||||
orig, edited, start_sample=SR // 2, fade_len_ms=10, sr=SR)
|
||||
|
||||
cases["dsp_utils.generate_peak_waveform"] = dsp_utils.generate_peak_waveform(p_stereo, 400)
|
||||
cases["dsp_utils.generate_rms_waveform"] = dsp_utils.generate_rms_waveform(p_stereo, 400)
|
||||
|
||||
# audio_editor
|
||||
seg = audio_editor.cut_and_loop_segment(
|
||||
p_stereo, 0.25, 0.75, loop_count=2, fade_in_ms=20, fade_out_ms=20,
|
||||
volume_db_change=-3.0)
|
||||
cases["audio_editor.cut_and_loop_segment"] = {
|
||||
"channels": seg.channels, "frame_rate": seg.frame_rate,
|
||||
"sample_width": seg.sample_width, "raw": seg.raw_data}
|
||||
|
||||
# bit_depth=32 (IEEE float) — KHONG dither, bit-exact. 16/24-bit dung TPDF
|
||||
# dither ngau nhien (np.random.default_rng()) nen output KHONG tai lap duoc.
|
||||
out_wav = os.path.join(tmp, "mix.wav")
|
||||
tracks_meta = [
|
||||
{"file_path": p_stereo, "volume": 0.8, "muted": False},
|
||||
{"file_path": p_mono, "volume": 0.6, "muted": False},
|
||||
{"file_path": p_click, "volume": 0.4, "muted": True},
|
||||
]
|
||||
audio_editor.mix_multitrack_session(tracks_meta, out_wav, sample_rate=SR, bit_depth=32)
|
||||
# Hash theo MAU audio (khong hash byte file): header WAV do pydub/ffmpeg ghi
|
||||
# co metadata khong on dinh giua cac lan chay, du samples giong het nhau.
|
||||
y_mix, sr_mix = sf.read(out_wav, dtype="float64")
|
||||
cases["audio_editor.mix_multitrack_session"] = {"sample_rate": sr_mix, "audio": y_mix}
|
||||
|
||||
# analyzer
|
||||
cases["analyzer.analyze_audio"] = analyzer.analyze_audio(p_click)
|
||||
|
||||
# loudness
|
||||
cases["loudness.integrated_lufs"] = loudness.integrated_lufs(stereo, SR)
|
||||
cases["loudness.true_peak_db"] = loudness.true_peak_db(stereo, SR)
|
||||
cases["loudness.mono_side_ratio"] = loudness.mono_side_ratio(stereo)
|
||||
return cases
|
||||
|
||||
|
||||
def build_manifest():
|
||||
import numpy as np
|
||||
import scipy
|
||||
cases = collect()
|
||||
return {
|
||||
"schema": 1,
|
||||
"generated_by": "tools/golden_conformance.py",
|
||||
"env": {"python": sys.version.split()[0], "numpy": np.__version__,
|
||||
"scipy": scipy.__version__, "sample_rate": SR},
|
||||
"cases": {k: _sha(v) for k, v in cases.items()},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--write", action="store_true")
|
||||
ap.add_argument("--check", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
manifest = build_manifest()
|
||||
if args.check:
|
||||
with io.open(MANIFEST, "r", encoding="utf-8") as f:
|
||||
ref = json.load(f)
|
||||
bad = []
|
||||
for k, h in ref["cases"].items():
|
||||
cur = manifest["cases"].get(k)
|
||||
if cur != h:
|
||||
bad.append(k)
|
||||
extra = [k for k in manifest["cases"] if k not in ref["cases"]]
|
||||
for k in bad:
|
||||
print("MISMATCH %s\n ref=%s\n cur=%s" % (k, ref["cases"][k], manifest["cases"][k]))
|
||||
for k in extra:
|
||||
print("NEW-CASE %s" % k)
|
||||
print("cases=%d mismatch=%d new=%d" % (len(ref["cases"]), len(bad), len(extra)))
|
||||
return 1 if bad else 0
|
||||
|
||||
os.makedirs(os.path.dirname(MANIFEST), exist_ok=True)
|
||||
with io.open(MANIFEST, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(manifest, f, indent=1, sort_keys=True)
|
||||
print("WROTE %s (%d cases)" % (MANIFEST, len(manifest["cases"])))
|
||||
for k, h in sorted(manifest["cases"].items()):
|
||||
print(" %-45s %s" % (k, h[:16]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,168 @@
|
||||
"""P0 baseline metrics - SonicForgeStudio (do truoc khi port Rust).
|
||||
|
||||
Do: kich thuoc bundle, thoi gian cold start + RAM cua daw_engine.exe,
|
||||
thoi gian import app.main, thoi gian mixdown 4 track 30s.
|
||||
|
||||
Chay: python tools/p0_baseline.py # in ra stdout + ghi md/BASELINE_P0.md
|
||||
python tools/p0_baseline.py --print # chi in
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
ENGINE_EXE = os.path.join(ROOT, "dist", "daw_engine", "daw_engine.exe")
|
||||
ENGINE_DIRS = ["dist/daw_engine", "src-tauri/resources/daw_engine"]
|
||||
OUT_MD = os.path.join(ROOT, "md", "BASELINE_P0.md")
|
||||
SR = 48000
|
||||
|
||||
|
||||
def dir_size_mb(rel):
|
||||
p = os.path.join(ROOT, rel)
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
total = files = 0
|
||||
for r, _d, fs in os.walk(p):
|
||||
for f in fs:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(r, f))
|
||||
files += 1
|
||||
except OSError:
|
||||
pass
|
||||
return {"mb": round(total / 1048576.0, 1), "files": files}
|
||||
|
||||
|
||||
def measure_bundle():
|
||||
out = {}
|
||||
for d in ENGINE_DIRS:
|
||||
s = dir_size_mb(d)
|
||||
if s:
|
||||
out[d] = s
|
||||
for sub in ("torch", "torchvision", "sympy", "networkx", "tensorflow"):
|
||||
p = os.path.join(ROOT, "dist", "daw_engine", "_internal", sub)
|
||||
out["has:" + sub] = os.path.exists(p)
|
||||
return out
|
||||
|
||||
|
||||
def _tasklist_mem_kb(pid):
|
||||
try:
|
||||
r = subprocess.run(["tasklist", "/FI", "PID eq %d" % pid, "/FO", "CSV", "/NH"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
row = r.stdout.strip().splitlines()[0]
|
||||
cells = [c.strip('"') for c in row.split('","')]
|
||||
return int(cells[-1].replace(",", "").replace(" K", "").strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def measure_cold_start(timeout_s=90):
|
||||
if not os.path.exists(ENGINE_EXE):
|
||||
return {"error": "engine exe khong ton tai: " + ENGINE_EXE}
|
||||
env = dict(os.environ)
|
||||
env["SF_PORT"] = "8000"
|
||||
t0 = time.perf_counter()
|
||||
proc = subprocess.Popen([ENGINE_EXE], cwd=os.path.dirname(ENGINE_EXE), env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
port = None
|
||||
try:
|
||||
while time.perf_counter() - t0 < timeout_s:
|
||||
if proc.poll() is not None:
|
||||
return {"error": "engine thoat som rc=%s" % proc.returncode}
|
||||
for p in range(8000, 8011):
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:%d/health" % p, timeout=0.5) as r:
|
||||
if r.status == 200:
|
||||
port = p
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if port:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
ms = round((time.perf_counter() - t0) * 1000.0, 1)
|
||||
mem = _tasklist_mem_kb(proc.pid)
|
||||
result = {"startup_ms": ms, "port": port, "rss_mb": round(mem / 1024.0, 1) if mem else None}
|
||||
finally:
|
||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
||||
capture_output=True, text=True)
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def measure_import_time():
|
||||
code = ("import time,sys;t=time.perf_counter();import app.main;"
|
||||
"print(round((time.perf_counter()-t)*1000,1))")
|
||||
t0 = time.perf_counter()
|
||||
r = subprocess.run([sys.executable, "-c", code], cwd=ROOT, capture_output=True, text=True)
|
||||
wall = round((time.perf_counter() - t0) * 1000.0, 1)
|
||||
try:
|
||||
imp = float(r.stdout.strip().splitlines()[-1])
|
||||
except Exception:
|
||||
imp = None
|
||||
return {"import_app_main_ms": imp, "subprocess_wall_ms": wall}
|
||||
|
||||
|
||||
def measure_render(reps=3):
|
||||
import numpy as np
|
||||
sys.path.insert(0, ROOT)
|
||||
from app.core import audio_editor
|
||||
tmp = tempfile.mkdtemp(prefix="sf_bench_")
|
||||
dur = 30.0
|
||||
n = int(SR * dur)
|
||||
t = np.arange(n) / SR
|
||||
paths = []
|
||||
for i, f in enumerate((110.0, 220.0, 330.0, 440.0)):
|
||||
y = (0.3 * np.sin(2 * np.pi * f * t)).astype(np.float32)
|
||||
p = os.path.join(tmp, "t%d.wav" % i)
|
||||
import soundfile as sf
|
||||
sf.write(p, np.column_stack([y, y]), SR, subtype="FLOAT")
|
||||
paths.append(p)
|
||||
meta = [{"file_path": p, "volume": 0.8, "muted": False} for p in paths]
|
||||
times = []
|
||||
for i in range(reps):
|
||||
out = os.path.join(tmp, "mix%d.wav" % i)
|
||||
t0 = time.perf_counter()
|
||||
audio_editor.mix_multitrack_session(meta, out, sample_rate=SR, bit_depth=32)
|
||||
times.append(round((time.perf_counter() - t0) * 1000.0, 1))
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return {"tracks": 4, "seconds_each": dur, "sr": SR,
|
||||
"runs_ms": times, "min_ms": min(times), "median_ms": round(statistics.median(times), 1),
|
||||
"realtime_factor": round(min(times) / (dur * 1000.0), 4)}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--print", action="store_true")
|
||||
args = ap.parse_args()
|
||||
metrics = {
|
||||
"date": time.strftime("%Y-%m-%d %H:%M"),
|
||||
"bundle": measure_bundle(),
|
||||
"cold_start": measure_cold_start(),
|
||||
"import": measure_import_time(),
|
||||
"render_mixdown": measure_render(),
|
||||
}
|
||||
text = "# Baseline P0 - SonicForgeStudio (Python engine)\n\n" \
|
||||
"Sinh boi `tools/p0_baseline.py` ngay %s. Dung lam moc so sanh khi port Rust.\n\n```json\n%s\n```\n" % (
|
||||
metrics["date"], json.dumps(metrics, indent=1, ensure_ascii=False))
|
||||
print(text)
|
||||
if not args.print:
|
||||
os.makedirs(os.path.dirname(OUT_MD), exist_ok=True)
|
||||
with io.open(OUT_MD, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(text)
|
||||
print("WROTE", OUT_MD)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user