D3+D4: export stage PCM16/24 + TPDF dither + noise shaping, resample 44.1/48/96k, normalize -1dBTP/-14LUFS, mono side/mid check; Export modal mastering options + /plugins/render options (test 9 case, 23 passed)
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user