# -*- coding: utf-8 -*- """D3: export stage — PCM_16/PCM_24 + TPDF dither + noise shaping, resample, normalize (-1dBTP / -14LUFS), mono-compat side/mid ratio. Không cần bridge: test track AUDIO thuần (không fx chain) → render thẳng qua PythonRenderEngine, chỉ động chạm khâu export stage. """ import os import sys import numpy as np import pytest REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, REPO) SR = 44100 def _write_wav(path, data, sr): import soundfile as sf sf.write(path, data.T, sr, subtype="FLOAT") def _audio_track(path): return { "id": "t1", "type": "AUDIO", "volume_db": 0.0, "pan": 0.0, "fx_chain": [], "vst_fx_chain": [], "vst_fx_bypass": False, "items": [{ "type": "AUDIO_ITEM", "start_bar": 0.0, "duration_bars": 1.0, "clip_start_offset_bars": 0.0, "source_data": {"audio_file_url": path}, }], } def _project(path): return { "metadata": {"bpm": 120.0, "time_signature_numerator": 4}, "main_session": {"length_bars": 1.0, "tracks": [_audio_track(path)]}, "section_store": {}, } def _render(engine, proj, out, **kw): engine.render_project(proj, out, **kw) import soundfile as sf info = sf.info(out) data, sr = sf.read(out, dtype="float32", always_2d=True) return data.T, sr, info def _sine_audio(dbfs, sec=2.0, freq=1000.0, sr=SR): t = np.arange(int(sr * sec)) / sr x = (10 ** (dbfs / 20.0)) * np.sin(2 * np.pi * freq * t) return np.stack([x, x]).astype(np.float32) # ── quantize ─────────────────────────────────────────────────────────────── def test_quantize_pcm16_grid_exact(): from app.core.render_engine import _quantize_pcm x = np.array([[0.0, 0.5, -0.5, 0.9999, 1.0, -1.0]]) q = _quantize_pcm(x, 16, False) assert q.dtype == np.int16 assert q[0, 0] == 0 assert q[0, 1] == 16384 # 0.5*32768 assert q[0, 2] == -16384 assert q[0, 3] == 32765 # 0.9999*32768=32765.44 → 32765 assert q[0, 4] == 32767 # 1.0 clip assert q[0, 5] == -32768 def test_dither_tpdf_noise_on_constant(): """Hằng số 0: không dither → toàn 0; dither TPDF → nhiễu quanh 0 (≤1 LSB).""" from app.core.render_engine import _quantize_pcm x = np.zeros((2, 20000)) q_nodith = _quantize_pcm(x, 16, False) assert np.all(q_nodith == 0) q_dith = _quantize_pcm(x, 16, True) assert np.std(q_dith.astype(np.float64)) > 0.0 assert np.max(np.abs(q_dith)) <= 1 def test_quantize_pcm24_int32_shift(): """PCM_24: int32 << 8 (libsndfile lưu top-24-bit).""" from app.core.render_engine import _quantize_pcm x = np.array([[0.5, -0.25]]) q = _quantize_pcm(x, 24, False) assert q.dtype == np.int32 assert q[0, 0] == 4194304 << 8 # 0.5*2^23 = 4194304, <<8 lên top-24-bit assert q[0, 1] == -2097152 << 8 assert np.all(q % 256 == 0) # ── engine export stage ──────────────────────────────────────────────────── def test_engine_pcm16_subtype_and_peak(tmp_path): from app.core.render_engine import PythonRenderEngine wav = str(tmp_path / "in.wav") _write_wav(wav, _sine_audio(-20.0), SR) out = str(tmp_path / "out16.wav") data, sr, info = _render(PythonRenderEngine(), _project(wav), out, bit_depth=16) assert info.subtype == "PCM_16" assert sr == SR import soundfile as sf di, _ = sf.read(out, dtype="int16", always_2d=True) assert di.dtype == np.int16 assert float(np.max(np.abs(di))) <= 32767 pk = float(np.max(np.abs(data))) assert abs(20 * np.log10(pk) - (-20.0)) < 0.1 def test_engine_pcm24_subtype(tmp_path): from app.core.render_engine import PythonRenderEngine wav = str(tmp_path / "in.wav") _write_wav(wav, _sine_audio(-20.0), SR) out = str(tmp_path / "out24.wav") data, sr, info = _render(PythonRenderEngine(), _project(wav), out, bit_depth=24) assert info.subtype == "PCM_24" assert sr == SR import soundfile as sf di, _ = sf.read(out, dtype="int32", always_2d=True) assert np.all(di % 256 == 0) pk = float(np.max(np.abs(data))) assert abs(20 * np.log10(pk) - (-20.0)) < 0.1 def test_engine_resample_48k(tmp_path): from app.core.render_engine import PythonRenderEngine wav = str(tmp_path / "in.wav") _write_wav(wav, _sine_audio(-20.0), SR) out = str(tmp_path / "out48.wav") data, sr, info = _render(PythonRenderEngine(), _project(wav), out, out_sample_rate=48000) assert info.subtype == "FLOAT" assert sr == 48000 # 1 bar @120bpm 4/4 = 2s: 88200 @44.1k → 96000 @48k assert abs(data.shape[1] - 96000) <= 16 def test_normalize_1dbtp(tmp_path): from app.core.render_engine import PythonRenderEngine from app.core import loudness wav = str(tmp_path / "in.wav") _write_wav(wav, _sine_audio(-20.0), SR) out = str(tmp_path / "norm_tp.wav") data, sr, _ = _render(PythonRenderEngine(), _project(wav), out, normalize=True, normalize_target="-1dbtp") tp = loudness.true_peak_db(data, sr) assert abs(tp - (-1.0)) < 0.15, f"dBTP {tp:.3f} != -1" def test_normalize_14lufs(tmp_path): from app.core.render_engine import PythonRenderEngine from app.core import loudness wav = str(tmp_path / "in.wav") _write_wav(wav, _sine_audio(-20.0), SR) out = str(tmp_path / "norm_lufs.wav") data, sr, _ = _render(PythonRenderEngine(), _project(wav), out, normalize=True, normalize_target="-14lufs") lufs = loudness.integrated_lufs(data, sr) assert abs(lufs - (-14.0)) < 0.5, f"LUFS {lufs:.3f} != -14" def test_mono_side_ratio(): from app.core import loudness sr = 44100 t = np.arange(sr) / sr mono = np.stack([0.5 * np.sin(2 * np.pi * 440 * t)] * 2) # L==R assert loudness.mono_side_ratio(mono) < 1e-9 wide = np.stack([0.5 * np.sin(2 * np.pi * 440 * t), -0.5 * np.sin(2 * np.pi * 440 * t)]) # side-only assert loudness.mono_side_ratio(wide) > 100.0