Files
SonicForgeStudio/tests/test_export_options.py
T

180 lines
6.3 KiB
Python

# -*- 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