Level 2: Psychoacoustic first version
This commit is contained in:
@@ -22,7 +22,7 @@ import soundfile as sf
|
||||
from core.aac_coder import aac_coder_1, aac_coder_2, aac_read_wav_stereo_48k
|
||||
from core.aac_decoder import aac_decoder_1, aac_decoder_2, aac_remove_padding
|
||||
from core.aac_types import *
|
||||
from core.aac_snr_db import snr_db
|
||||
from core.aac_utils import snr_db
|
||||
|
||||
|
||||
# Helper "fixtures" for aac_coder_1 / i_aac_coder_1
|
||||
@@ -222,4 +222,4 @@ def test_end_to_end_level_2_high_snr(tmp_stereo_wav: Path, tmp_path: Path) -> No
|
||||
assert int(fs_hat) == 48000
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert snr > 75.0
|
||||
assert snr > 80
|
||||
@@ -17,7 +17,7 @@ from typing import Sequence
|
||||
import pytest
|
||||
|
||||
from core.aac_filterbank import aac_filter_bank, aac_i_filter_bank
|
||||
from core.aac_snr_db import snr_db
|
||||
from core.aac_utils import snr_db
|
||||
from core.aac_types import *
|
||||
|
||||
# Helper fixtures for filterbank
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# ------------------------------------------------------------
|
||||
# AAC Coder/Decoder - Filterbank internal (mdct) Tests
|
||||
#
|
||||
# Multimedia course at Aristotle University of
|
||||
# Thessaloniki (AUTh)
|
||||
#
|
||||
# Author:
|
||||
# Christos Choutouridis (ΑΕΜ 8997)
|
||||
# cchoutou@ece.auth.gr
|
||||
#
|
||||
# Description:
|
||||
# Tests for Filterbank internal MDCT/IMDCT functionality.
|
||||
# ------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.aac_filterbank import _imdct, _mdct
|
||||
from core.aac_types import FloatArray, TimeSignal, MdctCoeffs
|
||||
|
||||
|
||||
def _assert_allclose(a: FloatArray, b: FloatArray, *, rtol: float, atol: float) -> None:
|
||||
"""
|
||||
Helper for consistent tolerances across tests.
|
||||
"""
|
||||
np.testing.assert_allclose(a, b, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
def _estimate_gain(y: MdctCoeffs, x: MdctCoeffs) -> float:
|
||||
"""
|
||||
Estimate scalar gain g such that y ~= g*x in least-squares sense.
|
||||
"""
|
||||
denom = float(np.dot(x, x))
|
||||
if denom == 0.0:
|
||||
return 0.0
|
||||
return float(np.dot(y, x) / denom)
|
||||
|
||||
|
||||
tolerance = 1e-10
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_mdct_imdct_mdct_identity_up_to_gain(N: int) -> None:
|
||||
"""
|
||||
Consistency test in coefficient domain:
|
||||
mdct(imdct(X)) ~= g * X
|
||||
|
||||
For the chosen (non-orthonormal) scaling, g is expected to be close to 2.
|
||||
"""
|
||||
rng = np.random.default_rng(0)
|
||||
K = N // 2
|
||||
|
||||
X: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
x: TimeSignal = _imdct(X)
|
||||
X_hat: MdctCoeffs = _mdct(x)
|
||||
|
||||
g = _estimate_gain(X_hat, X)
|
||||
_assert_allclose(X_hat, g * X, rtol=tolerance, atol=tolerance)
|
||||
_assert_allclose(np.array([g], dtype=np.float64), np.array([2.0], dtype=np.float64), rtol=tolerance, atol=tolerance)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_mdct_linearity(N: int) -> None:
|
||||
"""
|
||||
Linearity test:
|
||||
mdct(a*x + b*y) == a*mdct(x) + b*mdct(y)
|
||||
"""
|
||||
rng = np.random.default_rng(1)
|
||||
x: TimeSignal = rng.normal(size=N).astype(np.float64)
|
||||
y: TimeSignal = rng.normal(size=N).astype(np.float64)
|
||||
|
||||
a = 0.37
|
||||
b = -1.12
|
||||
|
||||
left: MdctCoeffs = _mdct(a * x + b * y)
|
||||
right: MdctCoeffs = a * _mdct(x) + b * _mdct(y)
|
||||
|
||||
_assert_allclose(left, right, rtol=tolerance, atol=tolerance)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_imdct_linearity(N: int) -> None:
|
||||
"""
|
||||
Linearity test for IMDCT:
|
||||
imdct(a*X + b*Y) == a*imdct(X) + b*imdct(Y)
|
||||
"""
|
||||
rng = np.random.default_rng(2)
|
||||
K = N // 2
|
||||
|
||||
X: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
Y: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
|
||||
a = -0.5
|
||||
b = 2.0
|
||||
|
||||
left: TimeSignal = _imdct(a * X + b * Y)
|
||||
right: TimeSignal = a * _imdct(X) + b * _imdct(Y)
|
||||
|
||||
_assert_allclose(left, right, rtol=tolerance, atol=tolerance)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_mdct_imdct_outputs_are_finite(N: int) -> None:
|
||||
"""
|
||||
Sanity test: no NaN/inf on random inputs.
|
||||
"""
|
||||
rng = np.random.default_rng(3)
|
||||
K = N // 2
|
||||
|
||||
x: TimeSignal = rng.normal(size=N).astype(np.float64)
|
||||
X: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
|
||||
X1 = _mdct(x)
|
||||
x1 = _imdct(X)
|
||||
|
||||
assert np.isfinite(X1).all()
|
||||
assert np.isfinite(x1).all()
|
||||
@@ -0,0 +1,253 @@
|
||||
# ------------------------------------------------------------
|
||||
# AAC Coder/Decoder - Psychoacoustic Model Tests
|
||||
#
|
||||
# Multimedia course at Aristotle University of
|
||||
# Thessaloniki (AUTh)
|
||||
#
|
||||
# Author:
|
||||
# Christos Choutouridis (ΑΕΜ 8997)
|
||||
#
|
||||
# Description:
|
||||
# Contract + sanity tests for the psychoacoustic model (core.aac_psycho).
|
||||
#
|
||||
# These tests focus on:
|
||||
# - output shapes per frame_type (long vs ESH),
|
||||
# - numerical sanity (finite, non-negative),
|
||||
# - deterministic behavior,
|
||||
# - ESH central-region dependency (outer regions must not affect result),
|
||||
# - basic input validation (length checks).
|
||||
#
|
||||
# We intentionally avoid asserting exact numeric values, because the model
|
||||
# includes FFT operations and table-driven psychoacoustic parameters.
|
||||
# ------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.aac_psycho import aac_psycho
|
||||
from core.aac_types import FrameChannelT, FrameType
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _make_frames(
|
||||
*,
|
||||
kind: str,
|
||||
amp: float = 1.0,
|
||||
seed: int = 0,
|
||||
) -> tuple[FrameChannelT, FrameChannelT, FrameChannelT]:
|
||||
"""
|
||||
Create (current, prev1, prev2) 2048-sample frames for one channel.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
kind : str
|
||||
"noise" or "tone".
|
||||
amp : float
|
||||
Amplitude scaling (applied to all frames).
|
||||
seed : int
|
||||
RNG seed for reproducibility (noise case).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[FrameChannelT, FrameChannelT, FrameChannelT]
|
||||
Three arrays of shape (2048,), dtype float64.
|
||||
"""
|
||||
if kind == "noise":
|
||||
rng = np.random.default_rng(seed)
|
||||
x2 = amp * rng.normal(size=2048).astype(np.float64)
|
||||
x1 = amp * rng.normal(size=2048).astype(np.float64)
|
||||
x0 = amp * rng.normal(size=2048).astype(np.float64)
|
||||
return x0, x1, x2
|
||||
|
||||
if kind == "tone":
|
||||
# A simple sinusoid which is identical across frames (highly predictable).
|
||||
n = np.arange(2048, dtype=np.float64)
|
||||
f0 = 13.0 # arbitrary normalized-bin-ish tone (not critical for these tests)
|
||||
tone = amp * np.sin(2.0 * np.pi * f0 * n / 2048.0).astype(np.float64)
|
||||
return tone, tone.copy(), tone.copy()
|
||||
|
||||
raise ValueError(f"Unknown kind: {kind!r}")
|
||||
|
||||
|
||||
def _assert_finite_nonnegative(x: np.ndarray) -> None:
|
||||
"""Utility assertions for psycho outputs."""
|
||||
assert np.isfinite(x).all()
|
||||
# SMR is a ratio of energies, it should not be negative.
|
||||
assert np.min(x) >= 0.0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shape / contract tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("frame_type", ["OLS", "LSS", "LPS"])
|
||||
def test_psycho_long_shapes(frame_type: FrameType) -> None:
|
||||
"""
|
||||
Contract test:
|
||||
For long frame types, psycho returns SMR shape (69,).
|
||||
"""
|
||||
x0, x1, x2 = _make_frames(kind="noise", seed=1, amp=1.0)
|
||||
|
||||
smr = aac_psycho(x0, frame_type, x1, x2)
|
||||
assert isinstance(smr, np.ndarray)
|
||||
assert smr.shape == (69,)
|
||||
_assert_finite_nonnegative(smr)
|
||||
|
||||
|
||||
def test_psycho_esh_shape() -> None:
|
||||
"""
|
||||
Contract test:
|
||||
For ESH, psycho returns SMR shape (42, 8).
|
||||
"""
|
||||
x0, x1, x2 = _make_frames(kind="noise", seed=2, amp=1.0)
|
||||
|
||||
smr = aac_psycho(x0, "ESH", x1, x2)
|
||||
assert isinstance(smr, np.ndarray)
|
||||
assert smr.shape == (42, 8)
|
||||
_assert_finite_nonnegative(smr)
|
||||
|
||||
|
||||
def test_psycho_is_deterministic_for_same_inputs() -> None:
|
||||
"""
|
||||
Determinism test:
|
||||
Psycho must return the same output for identical inputs.
|
||||
"""
|
||||
x0, x1, x2 = _make_frames(kind="noise", seed=3, amp=1.0)
|
||||
|
||||
smr1 = aac_psycho(x0, "OLS", x1, x2)
|
||||
smr2 = aac_psycho(x0, "OLS", x1, x2)
|
||||
|
||||
np.testing.assert_allclose(smr1, smr2, rtol=0.0, atol=0.0)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ESH-specific behavior tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def test_psycho_esh_ignores_outer_regions() -> None:
|
||||
"""
|
||||
Spec-driven behavior test:
|
||||
In this project, ESH uses only the central region of the 2048-sample frame to
|
||||
derive the 8 overlapping 256-sample subframes:
|
||||
start = 448 + 128*j, j=0..7
|
||||
|
||||
Therefore, changing samples outside [448, 1600) must not affect the output.
|
||||
"""
|
||||
rng = np.random.default_rng(10)
|
||||
|
||||
# Build base frames (current and prev1) with identical central region.
|
||||
center_cur = rng.normal(size=1152).astype(np.float64)
|
||||
center_prev1 = rng.normal(size=1152).astype(np.float64)
|
||||
|
||||
cur_a = np.zeros(2048, dtype=np.float64)
|
||||
cur_b = np.zeros(2048, dtype=np.float64)
|
||||
prev1_a = np.zeros(2048, dtype=np.float64)
|
||||
prev1_b = np.zeros(2048, dtype=np.float64)
|
||||
|
||||
cur_a[448:1600] = center_cur
|
||||
cur_b[448:1600] = center_cur
|
||||
prev1_a[448:1600] = center_prev1
|
||||
prev1_b[448:1600] = center_prev1
|
||||
|
||||
# Modify only outer regions in the *_b variants.
|
||||
cur_b[:448] = rng.normal(size=448)
|
||||
cur_b[1600:] = rng.normal(size=448)
|
||||
prev1_b[:448] = rng.normal(size=448)
|
||||
prev1_b[1600:] = rng.normal(size=448)
|
||||
|
||||
# prev2 is irrelevant for the chosen ESH history convention; keep it fixed.
|
||||
prev2 = rng.normal(size=2048).astype(np.float64)
|
||||
|
||||
smr_a = aac_psycho(cur_a, "ESH", prev1_a, prev2)
|
||||
smr_b = aac_psycho(cur_b, "ESH", prev1_b, prev2)
|
||||
|
||||
np.testing.assert_allclose(smr_a, smr_b, rtol=0.0, atol=0.0)
|
||||
|
||||
|
||||
def test_psycho_esh_columns_are_not_all_identical_for_random_input() -> None:
|
||||
"""
|
||||
Sanity test:
|
||||
For random input, different ESH subframes should typically produce
|
||||
different SMR columns (not a strict requirement, but a strong sanity signal).
|
||||
|
||||
We check that at least one column differs from another beyond a tiny tolerance.
|
||||
"""
|
||||
x0, x1, x2 = _make_frames(kind="noise", seed=11, amp=1.0)
|
||||
|
||||
smr = aac_psycho(x0, "ESH", x1, x2)
|
||||
# Compare column 0 vs column 7; for random signals they should differ.
|
||||
diff = np.max(np.abs(smr[:, 0] - smr[:, 7]))
|
||||
assert diff > 1e-12
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Scaling sanity (avoid fragile numeric targets)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def test_psycho_long_smr_is_mostly_monotone_with_amplitude() -> None:
|
||||
"""
|
||||
Sanity test:
|
||||
Increasing signal amplitude should not reduce the SMR for the vast majority
|
||||
of Bark bands.
|
||||
|
||||
Due to the use of max(nb, qthr), a small fraction of bands close to the
|
||||
threshold-in-quiet boundary may violate strict monotonicity. This is expected
|
||||
behavior, so we test a percentage-based criterion instead of a strict one.
|
||||
"""
|
||||
x0, x1, x2 = _make_frames(kind="noise", seed=20, amp=1e3)
|
||||
y0, y1, y2 = (2.0 * x0, 2.0 * x1, 2.0 * x2)
|
||||
|
||||
smr1 = aac_psycho(x0, "OLS", x1, x2)
|
||||
smr2 = aac_psycho(y0, "OLS", y1, y2)
|
||||
|
||||
eps = 1e-12
|
||||
nondecreasing = np.sum(smr2 + eps >= smr1)
|
||||
|
||||
ratio = nondecreasing / smr1.size
|
||||
|
||||
# Expect monotonic behavior for the overwhelming majority of bands.
|
||||
assert ratio >= 0.95
|
||||
|
||||
def test_psycho_long_is_approximately_scale_invariant_at_high_level() -> None:
|
||||
"""
|
||||
Sanity test (robust):
|
||||
At high levels, SMR should be approximately scale-invariant for most bands.
|
||||
Some bands may deviate due to the max(nb, qthr) branch.
|
||||
"""
|
||||
x0, x1, x2 = _make_frames(kind="noise", seed=20, amp=1e3)
|
||||
y0, y1, y2 = (2.0 * x0, 2.0 * x1, 2.0 * x2)
|
||||
|
||||
smr1 = aac_psycho(x0, "OLS", x1, x2)
|
||||
smr2 = aac_psycho(y0, "OLS", y1, y2)
|
||||
|
||||
rel = np.abs(smr2 - smr1) / np.maximum(np.abs(smr1), 1e-12)
|
||||
|
||||
# Most bands should be close (<= 5%), but allow a small number of outliers.
|
||||
close = np.sum(rel <= 5e-2)
|
||||
assert close >= (smr1.size - 2) # allow up to 2 bands to deviate
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Input validation tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def test_psycho_rejects_wrong_lengths() -> None:
|
||||
"""
|
||||
Contract test:
|
||||
aac_psycho requires 2048-sample frames for current/prev1/prev2.
|
||||
"""
|
||||
x = np.zeros(2048, dtype=np.float64)
|
||||
bad = np.zeros(2047, dtype=np.float64)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = aac_psycho(bad, "OLS", x, x)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = aac_psycho(x, "OLS", bad, x)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = aac_psycho(x, "OLS", x, bad)
|
||||
@@ -1,98 +0,0 @@
|
||||
# ------------------------------------------------------------
|
||||
# AAC Coder/Decoder - SNR dB Tests
|
||||
#
|
||||
# Multimedia course at Aristotle University of
|
||||
# Thessaloniki (AUTh)
|
||||
#
|
||||
# Author:
|
||||
# Christos Choutouridis (ΑΕΜ 8997)
|
||||
# cchoutou@ece.auth.gr
|
||||
#
|
||||
# Description:
|
||||
# Basic tests for SNR calculation utility.
|
||||
# ------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.aac_snr_db import snr_db
|
||||
from core.aac_types import StereoSignal
|
||||
|
||||
|
||||
def test_snr_perfect_reconstruction_returns_inf() -> None:
|
||||
"""
|
||||
If x_hat == x_ref exactly, noise power is zero and SNR must be +inf.
|
||||
"""
|
||||
rng = np.random.default_rng(0)
|
||||
x: StereoSignal = rng.normal(size=(1024, 2)).astype(np.float64)
|
||||
|
||||
snr = snr_db(x, x)
|
||||
assert snr == float("inf")
|
||||
|
||||
|
||||
def test_snr_zero_reference_returns_minus_inf() -> None:
|
||||
"""
|
||||
If reference signal is identically zero, signal power is zero
|
||||
and SNR must be -inf (unless noise is also zero, which is degenerate).
|
||||
"""
|
||||
x_ref: StereoSignal = np.zeros((1024, 2), dtype=np.float64)
|
||||
x_hat: StereoSignal = np.ones((1024, 2), dtype=np.float64)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert snr == float("-inf")
|
||||
|
||||
|
||||
def test_snr_known_noise_level_matches_expected_value() -> None:
|
||||
"""
|
||||
Deterministic test with known signal and noise power.
|
||||
|
||||
Let:
|
||||
x_ref = ones
|
||||
x_hat = ones + noise
|
||||
|
||||
With noise variance sigma^2, expected SNR:
|
||||
10 * log10(Ps / Pn)
|
||||
"""
|
||||
n = 1000
|
||||
sigma = 0.1
|
||||
|
||||
x_ref: StereoSignal = np.ones((n, 2), dtype=np.float64)
|
||||
noise = sigma * np.ones((n, 2), dtype=np.float64)
|
||||
x_hat: StereoSignal = x_ref + noise
|
||||
|
||||
ps = float(np.sum(x_ref * x_ref))
|
||||
pn = float(np.sum(noise * noise))
|
||||
expected = 10.0 * np.log10(ps / pn)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert np.isclose(snr, expected, rtol=1e-12, atol=1e-12)
|
||||
|
||||
|
||||
def test_snr_aligns_different_lengths_and_channels() -> None:
|
||||
"""
|
||||
The function must:
|
||||
- align to minimum length
|
||||
- align to minimum channel count
|
||||
without crashing.
|
||||
"""
|
||||
rng = np.random.default_rng(1)
|
||||
|
||||
x_ref: StereoSignal = rng.normal(size=(1000, 2)).astype(np.float64)
|
||||
x_hat: StereoSignal = rng.normal(size=(800, 1)).astype(np.float64)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert np.isfinite(snr)
|
||||
|
||||
|
||||
def test_snr_accepts_1d_inputs() -> None:
|
||||
"""
|
||||
1-D inputs must be accepted and treated as single-channel signals.
|
||||
"""
|
||||
rng = np.random.default_rng(2)
|
||||
|
||||
x_ref = rng.normal(size=1024).astype(np.float64)
|
||||
x_hat = x_ref + 0.01 * rng.normal(size=1024).astype(np.float64)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert np.isfinite(snr)
|
||||
@@ -0,0 +1,329 @@
|
||||
# ------------------------------------------------------------
|
||||
# AAC Coder/Decoder - SNR dB Tests
|
||||
#
|
||||
# Multimedia course at Aristotle University of
|
||||
# Thessaloniki (AUTh)
|
||||
#
|
||||
# Author:
|
||||
# Christos Choutouridis (ΑΕΜ 8997)
|
||||
# cchoutou@ece.auth.gr
|
||||
#
|
||||
# Description:
|
||||
# - Basic tests for SNR calculation utility.
|
||||
# - Contract and sanity tests for TableB219-related utilities.
|
||||
#
|
||||
# These tests do NOT validate the numerical correctness of the
|
||||
# Bark tables themselves (they are given by the AAC spec),
|
||||
# but instead ensure:
|
||||
# - correct loading from disk,
|
||||
# - correct table selection per frame type,
|
||||
# - internal consistency of band limits,
|
||||
# - correct caching behavior.
|
||||
# ------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.aac_utils import mdct, imdct, snr_db, load_b219_tables, get_table, band_limits
|
||||
from core.aac_types import *
|
||||
|
||||
tolerance = 1e-10
|
||||
|
||||
# mdct / imdct
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def _assert_allclose(a: FloatArray, b: FloatArray, *, rtol: float, atol: float) -> None:
|
||||
"""
|
||||
Helper for consistent tolerances across tests.
|
||||
"""
|
||||
np.testing.assert_allclose(a, b, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
def _estimate_gain(y: MdctCoeffs, x: MdctCoeffs) -> float:
|
||||
"""
|
||||
Estimate scalar gain g such that y ~= g*x in least-squares sense.
|
||||
"""
|
||||
denom = float(np.dot(x, x))
|
||||
if denom == 0.0:
|
||||
return 0.0
|
||||
return float(np.dot(y, x) / denom)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_mdct_imdct_mdct_identity_up_to_gain(N: int) -> None:
|
||||
"""
|
||||
Consistency test in coefficient domain:
|
||||
mdct(imdct(X)) ~= g * X
|
||||
|
||||
For the chosen (non-orthonormal) scaling, g is expected to be close to 2.
|
||||
"""
|
||||
rng = np.random.default_rng(0)
|
||||
K = N // 2
|
||||
|
||||
X: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
x: TimeSignal = imdct(X)
|
||||
X_hat: MdctCoeffs = mdct(x)
|
||||
|
||||
g = _estimate_gain(X_hat, X)
|
||||
_assert_allclose(X_hat, g * X, rtol=tolerance, atol=tolerance)
|
||||
_assert_allclose(np.array([g], dtype=np.float64), np.array([2.0], dtype=np.float64), rtol=tolerance, atol=tolerance)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_mdct_linearity(N: int) -> None:
|
||||
"""
|
||||
Linearity test:
|
||||
mdct(a*x + b*y) == a*mdct(x) + b*mdct(y)
|
||||
"""
|
||||
rng = np.random.default_rng(1)
|
||||
x: TimeSignal = rng.normal(size=N).astype(np.float64)
|
||||
y: TimeSignal = rng.normal(size=N).astype(np.float64)
|
||||
|
||||
a = 0.37
|
||||
b = -1.12
|
||||
|
||||
left: MdctCoeffs = mdct(a * x + b * y)
|
||||
right: MdctCoeffs = a * mdct(x) + b * mdct(y)
|
||||
|
||||
_assert_allclose(left, right, rtol=tolerance, atol=tolerance)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_imdct_linearity(N: int) -> None:
|
||||
"""
|
||||
Linearity test for IMDCT:
|
||||
imdct(a*X + b*Y) == a*imdct(X) + b*imdct(Y)
|
||||
"""
|
||||
rng = np.random.default_rng(2)
|
||||
K = N // 2
|
||||
|
||||
X: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
Y: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
|
||||
a = -0.5
|
||||
b = 2.0
|
||||
|
||||
left: TimeSignal = imdct(a * X + b * Y)
|
||||
right: TimeSignal = a * imdct(X) + b * imdct(Y)
|
||||
|
||||
_assert_allclose(left, right, rtol=tolerance, atol=tolerance)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("N", [256, 2048])
|
||||
def test_mdct_imdct_outputs_are_finite(N: int) -> None:
|
||||
"""
|
||||
Sanity test: no NaN/inf on random inputs.
|
||||
"""
|
||||
rng = np.random.default_rng(3)
|
||||
K = N // 2
|
||||
|
||||
x: TimeSignal = rng.normal(size=N).astype(np.float64)
|
||||
X: MdctCoeffs = rng.normal(size=K).astype(np.float64)
|
||||
|
||||
X1 = mdct(x)
|
||||
x1 = imdct(X)
|
||||
|
||||
assert np.isfinite(X1).all()
|
||||
assert np.isfinite(x1).all()
|
||||
|
||||
|
||||
|
||||
# SNR
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def test_snr_perfect_reconstruction_returns_inf() -> None:
|
||||
"""
|
||||
If x_hat == x_ref exactly, noise power is zero and SNR must be +inf.
|
||||
"""
|
||||
rng = np.random.default_rng(0)
|
||||
x: StereoSignal = rng.normal(size=(1024, 2)).astype(np.float64)
|
||||
|
||||
snr = snr_db(x, x)
|
||||
assert snr == float("inf")
|
||||
|
||||
|
||||
def test_snr_zero_reference_returns_minus_inf() -> None:
|
||||
"""
|
||||
If reference signal is identically zero, signal power is zero
|
||||
and SNR must be -inf (unless noise is also zero, which is degenerate).
|
||||
"""
|
||||
x_ref: StereoSignal = np.zeros((1024, 2), dtype=np.float64)
|
||||
x_hat: StereoSignal = np.ones((1024, 2), dtype=np.float64)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert snr == float("-inf")
|
||||
|
||||
|
||||
def test_snr_known_noise_level_matches_expected_value() -> None:
|
||||
"""
|
||||
Deterministic test with known signal and noise power.
|
||||
|
||||
Let:
|
||||
x_ref = ones
|
||||
x_hat = ones + noise
|
||||
|
||||
With noise variance sigma^2, expected SNR:
|
||||
10 * log10(Ps / Pn)
|
||||
"""
|
||||
n = 1000
|
||||
sigma = 0.1
|
||||
|
||||
x_ref: StereoSignal = np.ones((n, 2), dtype=np.float64)
|
||||
noise = sigma * np.ones((n, 2), dtype=np.float64)
|
||||
x_hat: StereoSignal = x_ref + noise
|
||||
|
||||
ps = float(np.sum(x_ref * x_ref))
|
||||
pn = float(np.sum(noise * noise))
|
||||
expected = 10.0 * np.log10(ps / pn)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert np.isclose(snr, expected, rtol=1e-12, atol=1e-12)
|
||||
|
||||
|
||||
def test_snr_aligns_different_lengths_and_channels() -> None:
|
||||
"""
|
||||
The function must:
|
||||
- align to minimum length
|
||||
- align to minimum channel count
|
||||
without crashing.
|
||||
"""
|
||||
rng = np.random.default_rng(1)
|
||||
|
||||
x_ref: StereoSignal = rng.normal(size=(1000, 2)).astype(np.float64)
|
||||
x_hat: StereoSignal = rng.normal(size=(800, 1)).astype(np.float64)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert np.isfinite(snr)
|
||||
|
||||
|
||||
def test_snr_accepts_1d_inputs() -> None:
|
||||
"""
|
||||
1-D inputs must be accepted and treated as single-channel signals.
|
||||
"""
|
||||
rng = np.random.default_rng(2)
|
||||
|
||||
x_ref = rng.normal(size=1024).astype(np.float64)
|
||||
x_hat = x_ref + 0.01 * rng.normal(size=1024).astype(np.float64)
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert np.isfinite(snr)
|
||||
|
||||
|
||||
|
||||
# Table219b
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def test_load_b219_tables_returns_expected_keys() -> None:
|
||||
"""
|
||||
Contract test:
|
||||
TableB219.mat must load successfully and expose both tables
|
||||
required by the psychoacoustic model.
|
||||
|
||||
The AAC spec defines:
|
||||
- B219a: long-frame Bark bands
|
||||
- B219b: short-frame Bark bands
|
||||
"""
|
||||
tables = load_b219_tables()
|
||||
|
||||
assert isinstance(tables, dict)
|
||||
assert "B219a" in tables
|
||||
assert "B219b" in tables
|
||||
|
||||
|
||||
def test_b219_table_shapes_are_correct() -> None:
|
||||
"""
|
||||
Sanity test:
|
||||
Verify that the Bark tables have the expected number of bands
|
||||
and sufficient columns.
|
||||
|
||||
Expected from AAC spec:
|
||||
- B219a: 69 bands (long frames)
|
||||
- B219b: 42 bands (short frames)
|
||||
- At least 6 columns (as accessed by band_limits()).
|
||||
"""
|
||||
tables = load_b219_tables()
|
||||
|
||||
B219a = tables["B219a"]
|
||||
assert B219a.ndim == 2
|
||||
assert B219a.shape[0] == 69
|
||||
assert B219a.shape[1] >= 6
|
||||
|
||||
B219b = tables["B219b"]
|
||||
assert B219b.ndim == 2
|
||||
assert B219b.shape[0] == 42
|
||||
assert B219b.shape[1] >= 6
|
||||
|
||||
|
||||
def test_get_table_returns_correct_fft_size() -> None:
|
||||
"""
|
||||
Interface test:
|
||||
get_table(frame_type) must return both:
|
||||
- the correct Bark table
|
||||
- the correct FFT size N
|
||||
|
||||
This mapping is fundamental for the psychoacoustic model.
|
||||
"""
|
||||
table_long, N_long = get_table("OLS")
|
||||
assert N_long == 2048
|
||||
assert table_long.shape[0] == 69
|
||||
|
||||
table_short, N_short = get_table("ESH")
|
||||
assert N_short == 256
|
||||
assert table_short.shape[0] == 42
|
||||
|
||||
|
||||
def test_band_limits_are_consistent_for_long_table() -> None:
|
||||
"""
|
||||
Sanity test for band limits (long frames):
|
||||
|
||||
For each Bark band:
|
||||
- wlow <= whigh
|
||||
- frequency indices stay within [0, N/2)
|
||||
- all returned arrays have consistent lengths
|
||||
"""
|
||||
table, N = get_table("OLS")
|
||||
wlow, whigh, bval, qthr = band_limits(table)
|
||||
|
||||
B = table.shape[0]
|
||||
assert len(wlow) == B
|
||||
assert len(whigh) == B
|
||||
assert len(bval) == B
|
||||
assert len(qthr) == B
|
||||
|
||||
for b in range(B):
|
||||
assert 0 <= wlow[b] <= whigh[b]
|
||||
assert whigh[b] < N // 2
|
||||
|
||||
|
||||
def test_band_limits_are_consistent_for_short_table() -> None:
|
||||
"""
|
||||
Sanity test for band limits (short frames / ESH).
|
||||
|
||||
Same invariants as for long frames, but with FFT size N=256.
|
||||
"""
|
||||
table, N = get_table("ESH")
|
||||
wlow, whigh, bval, qthr = band_limits(table)
|
||||
|
||||
B = table.shape[0]
|
||||
assert len(wlow) == B
|
||||
assert len(whigh) == B
|
||||
|
||||
for b in range(B):
|
||||
assert 0 <= wlow[b] <= whigh[b]
|
||||
assert whigh[b] < N // 2
|
||||
|
||||
|
||||
def test_b219_tables_are_cached() -> None:
|
||||
"""
|
||||
Implementation test:
|
||||
load_b219_tables() should cache the loaded tables so that
|
||||
subsequent calls return the same object (identity check).
|
||||
|
||||
This avoids repeated disk I/O during psychoacoustic analysis.
|
||||
"""
|
||||
t1 = load_b219_tables()
|
||||
t2 = load_b219_tables()
|
||||
|
||||
assert t1 is t2
|
||||
Reference in New Issue
Block a user