Level 2: Core functionality and level_2 script wrappers added
This commit is contained in:
@@ -19,59 +19,15 @@ import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
|
||||
from core.aac_coder import aac_coder_1
|
||||
from core.aac_decoder import aac_decoder_1
|
||||
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
|
||||
|
||||
|
||||
# Helper "fixtures" for aac_coder_1 / i_aac_coder_1
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _snr_db(x_ref: StereoSignal, x_hat: StereoSignal) -> float:
|
||||
"""
|
||||
Compute overall SNR (dB) over all samples and channels after aligning lengths.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
x_ref : StereoSignal
|
||||
Reference signal, shape (N, 2) typical.
|
||||
x_hat : StereoSignal
|
||||
Reconstructed signal, shape (M, 2) typical.
|
||||
|
||||
Returns
|
||||
-------
|
||||
float
|
||||
SNR in dB.
|
||||
- Returns +inf if noise power is zero.
|
||||
- Returns -inf if signal power is zero.
|
||||
"""
|
||||
x_ref = np.asarray(x_ref, dtype=np.float64)
|
||||
x_hat = np.asarray(x_hat, dtype=np.float64)
|
||||
|
||||
# Be conservative: align lengths and common channels.
|
||||
if x_ref.ndim == 1:
|
||||
x_ref = x_ref.reshape(-1, 1)
|
||||
if x_hat.ndim == 1:
|
||||
x_hat = x_hat.reshape(-1, 1)
|
||||
|
||||
n = min(x_ref.shape[0], x_hat.shape[0])
|
||||
c = min(x_ref.shape[1], x_hat.shape[1])
|
||||
|
||||
x_ref = x_ref[:n, :c]
|
||||
x_hat = x_hat[:n, :c]
|
||||
|
||||
err = x_ref - x_hat
|
||||
ps = float(np.sum(x_ref * x_ref))
|
||||
pn = float(np.sum(err * err))
|
||||
|
||||
if pn <= 0.0:
|
||||
return float("inf")
|
||||
if ps <= 0.0:
|
||||
return float("-inf")
|
||||
|
||||
return float(10.0 * np.log10(ps / pn))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_stereo_wav(tmp_path: Path) -> Path:
|
||||
"""
|
||||
@@ -89,6 +45,56 @@ def tmp_stereo_wav(tmp_path: Path) -> Path:
|
||||
return wav_path
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper-function tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def test_aac_read_wav_stereo_48k_roundtrip(tmp_stereo_wav: Path) -> None:
|
||||
"""
|
||||
Contract test for aac_read_wav_stereo_48k():
|
||||
- Reads stereo WAV
|
||||
- Returns float64 array with shape (N,2)
|
||||
- Returns fs = 48000
|
||||
"""
|
||||
x, fs = aac_read_wav_stereo_48k(tmp_stereo_wav)
|
||||
|
||||
assert int(fs) == 48000
|
||||
assert isinstance(x, np.ndarray)
|
||||
assert x.dtype == np.float64
|
||||
assert x.ndim == 2
|
||||
assert x.shape[1] == 2
|
||||
assert x.shape[0] > 0
|
||||
|
||||
|
||||
def test_aac_remove_padding_removes_hop_from_both_ends() -> None:
|
||||
"""
|
||||
Contract test for aac_remove_padding():
|
||||
- Removes 'hop' samples from start and end.
|
||||
"""
|
||||
hop = 1024
|
||||
n = 10000
|
||||
|
||||
y_pad: StereoSignal = np.zeros((n, 2), dtype=np.float64)
|
||||
y: StereoSignal = aac_remove_padding(y_pad, hop=hop)
|
||||
|
||||
assert y.shape == (n - 2 * hop, 2)
|
||||
assert y.dtype == np.float64
|
||||
|
||||
|
||||
def test_aac_remove_padding_errors_on_too_short_input() -> None:
|
||||
"""
|
||||
aac_remove_padding must raise if y_pad is shorter than 2*hop.
|
||||
"""
|
||||
hop = 1024
|
||||
y_pad: StereoSignal = np.zeros((2 * hop - 1, 2), dtype=np.float64)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = aac_remove_padding(y_pad, hop=hop)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Level 1 tests
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_aac_coder_seq_schema_and_shapes(tmp_stereo_wav: Path) -> None:
|
||||
"""
|
||||
Module-level contract test:
|
||||
@@ -152,5 +158,68 @@ def test_end_to_end_aac_coder_decoder_high_snr(tmp_stereo_wav: Path, tmp_path: P
|
||||
assert int(fs_hat) == 48000
|
||||
|
||||
# SNR against returned array (file should match closely, but we do not require it here).
|
||||
snr = _snr_db(x_ref, x_hat)
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert snr > 80.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Level 2 tests (new)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def test_aac_coder_2_seq_schema_and_shapes(tmp_stereo_wav: Path) -> None:
|
||||
"""
|
||||
Module-level contract test (Level 2):
|
||||
Ensure aac_seq_2 follows the expected schema and per-frame shapes, including tns_coeffs.
|
||||
"""
|
||||
aac_seq: AACSeq2 = aac_coder_2(tmp_stereo_wav)
|
||||
|
||||
assert isinstance(aac_seq, list)
|
||||
assert len(aac_seq) > 0
|
||||
|
||||
for fr in aac_seq:
|
||||
assert "frame_type" in fr
|
||||
assert "win_type" in fr
|
||||
assert "chl" in fr
|
||||
assert "chr" in fr
|
||||
|
||||
frame_type: FrameType = fr["frame_type"]
|
||||
assert frame_type in ("OLS", "LSS", "ESH", "LPS")
|
||||
|
||||
for ch_key in ("chl", "chr"):
|
||||
ch = fr[ch_key]
|
||||
assert "frame_F" in ch
|
||||
assert "tns_coeffs" in ch
|
||||
|
||||
frame_f = np.asarray(ch["frame_F"], dtype=np.float64)
|
||||
coeffs = np.asarray(ch["tns_coeffs"], dtype=np.float64)
|
||||
|
||||
if frame_type == "ESH":
|
||||
assert frame_f.shape == (128, 8)
|
||||
assert coeffs.shape[0] == 4
|
||||
assert coeffs.shape[1] == 8
|
||||
else:
|
||||
assert frame_f.shape == (1024, 1)
|
||||
assert coeffs.shape == (4, 1)
|
||||
|
||||
|
||||
def test_end_to_end_level_2_high_snr(tmp_stereo_wav: Path, tmp_path: Path) -> None:
|
||||
"""
|
||||
End-to-end test (Level 2):
|
||||
Encode + decode and check SNR remains very high.
|
||||
|
||||
Level 2 is still floating-point (TNS is reversible), so reconstruction
|
||||
should remain numerical-noise only.
|
||||
"""
|
||||
x_ref, fs = sf.read(str(tmp_stereo_wav), always_2d=True)
|
||||
x_ref = np.asarray(x_ref, dtype=np.float64)
|
||||
assert int(fs) == 48000
|
||||
|
||||
out_wav = tmp_path / "out_l2.wav"
|
||||
aac_seq = aac_coder_2(tmp_stereo_wav)
|
||||
x_hat: StereoSignal = aac_decoder_2(aac_seq, out_wav)
|
||||
|
||||
assert out_wav.exists()
|
||||
_, fs_hat = sf.read(str(out_wav), always_2d=True)
|
||||
assert int(fs_hat) == 48000
|
||||
|
||||
snr = snr_db(x_ref, x_hat)
|
||||
assert snr > 75.0
|
||||
@@ -17,6 +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_types import *
|
||||
|
||||
# Helper fixtures for filterbank
|
||||
@@ -56,20 +57,6 @@ def _ola_reconstruct(x: StereoSignal, frame_types: Sequence[FrameType], win_type
|
||||
return y
|
||||
|
||||
|
||||
def _snr_db(x: StereoSignal, y: StereoSignal) -> float:
|
||||
"""
|
||||
Compute SNR in dB over all samples/channels.
|
||||
"""
|
||||
err = x - y
|
||||
ps = float(np.sum(x * x))
|
||||
pn = float(np.sum(err * err))
|
||||
if pn <= 0.0:
|
||||
return float("inf")
|
||||
if ps <= 0.0:
|
||||
return float("-inf")
|
||||
return 10.0 * float(np.log10(ps / pn))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Forward filterbank tests
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -223,7 +210,7 @@ def test_ola_reconstruction_ols_high_snr(win_type: WinType) -> None:
|
||||
|
||||
a = 1024
|
||||
b = N - 1024
|
||||
snr = _snr_db(x[a:b, :], y[a:b, :])
|
||||
snr = snr_db(x[a:b, :], y[a:b, :])
|
||||
assert snr > 50.0
|
||||
|
||||
|
||||
@@ -244,7 +231,7 @@ def test_ola_reconstruction_esh_high_snr(win_type: WinType) -> None:
|
||||
|
||||
a = 1024
|
||||
b = N - 1024
|
||||
snr = _snr_db(x[a:b, :], y[a:b, :])
|
||||
snr = snr_db(x[a:b, :], y[a:b, :])
|
||||
assert snr > 45.0
|
||||
|
||||
|
||||
@@ -265,5 +252,5 @@ def test_ola_reconstruction_transition_sequence(win_type: WinType) -> None:
|
||||
|
||||
a = 1024
|
||||
b = N - 1024
|
||||
snr = _snr_db(x[a:b, :], y[a:b, :])
|
||||
snr = snr_db(x[a:b, :], y[a:b, :])
|
||||
assert snr > 40.0
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# ------------------------------------------------------------
|
||||
# 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,196 @@
|
||||
# ------------------------------------------------------------
|
||||
# AAC Coder/Decoder - TNS Tests
|
||||
#
|
||||
# Multimedia course at Aristotle University of
|
||||
# Thessaloniki (AUTh)
|
||||
#
|
||||
# Author:
|
||||
# Christos Choutouridis (ΑΕΜ 8997)
|
||||
# cchoutou@ece.auth.gr
|
||||
#
|
||||
# Description:
|
||||
# Tests for Temporal Noise Shaping (TNS) module (Level 2).
|
||||
#
|
||||
# Validates:
|
||||
# - I/O shapes for long and ESH modes
|
||||
# - Quantization grid and clamping of predictor coefficients
|
||||
# - Inverse-filter stability (all poles inside unit circle)
|
||||
# - Functional correctness: iTNS(TNS(X)) ≈ X
|
||||
# ------------------------------------------------------------
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.aac_configuration import PRED_ORDER, QUANT_MAX, QUANT_STEP
|
||||
from core.aac_tns import aac_tns, aac_i_tns
|
||||
from core.aac_types import *
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper utilities
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _is_inverse_stable_from_coeffs(a_q: MdctCoeffs) -> bool:
|
||||
"""
|
||||
Check stability of the inverse TNS filter H_TNS^{-1}.
|
||||
|
||||
Poles are roots of:
|
||||
z^p - a1 z^{p-1} - ... - ap = 0
|
||||
|
||||
Stability condition:
|
||||
|root| < 1 for all roots.
|
||||
"""
|
||||
a_q = np.asarray(a_q, dtype=np.float64).reshape(-1)
|
||||
p = int(a_q.shape[0])
|
||||
|
||||
poly = np.empty(p + 1, dtype=np.float64)
|
||||
poly[0] = 1.0
|
||||
poly[1:] = -a_q
|
||||
|
||||
roots = np.roots(poly)
|
||||
margin = 1e-12
|
||||
return bool(np.all(np.abs(roots) < (1.0 - margin)))
|
||||
|
||||
|
||||
def _assert_quantized_and_clamped(a_q: MdctCoeffs) -> None:
|
||||
"""
|
||||
Assert that coefficients:
|
||||
- lie on the QUANT_STEP grid
|
||||
- are clamped to [-QUANT_MAX, +QUANT_MAX]
|
||||
"""
|
||||
a_q = np.asarray(a_q, dtype=np.float64)
|
||||
|
||||
assert np.max(np.abs(a_q)) <= (QUANT_MAX + 1e-12)
|
||||
|
||||
grid = a_q / float(QUANT_STEP)
|
||||
assert np.max(np.abs(grid - np.round(grid))) < 1e-12
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shape / contract tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("frame_type", ["OLS", "LSS", "LPS"])
|
||||
def test_tns_shapes_long_sequences(frame_type: FrameType) -> None:
|
||||
"""
|
||||
Contract test (long frames):
|
||||
- Input shape: (1024, 1)
|
||||
- Output shape preserved
|
||||
- Predictor coeffs shape: (PRED_ORDER, 1)
|
||||
"""
|
||||
rng = np.random.default_rng(0)
|
||||
frame_F_in: FrameChannelF = rng.normal(size=(1024, 1)).astype(np.float64)
|
||||
|
||||
frame_F_out, tns_coeffs = aac_tns(frame_F_in, frame_type)
|
||||
|
||||
assert frame_F_out.shape == frame_F_in.shape
|
||||
assert tns_coeffs.shape == (PRED_ORDER, 1)
|
||||
|
||||
|
||||
def test_tns_shapes_esh() -> None:
|
||||
"""
|
||||
Contract test (ESH):
|
||||
- Input shape: (128, 8)
|
||||
- Output shape preserved
|
||||
- Predictor coeffs shape: (PRED_ORDER, 8)
|
||||
"""
|
||||
rng = np.random.default_rng(1)
|
||||
frame_F_in: FrameChannelF = rng.normal(size=(128, 8)).astype(np.float64)
|
||||
|
||||
frame_F_out, tns_coeffs = aac_tns(frame_F_in, "ESH")
|
||||
|
||||
assert frame_F_out.shape == (128, 8)
|
||||
assert tns_coeffs.shape == (PRED_ORDER, 8)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Coefficient properties
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("frame_type", ["OLS", "LSS", "LPS"])
|
||||
def test_tns_coeffs_quantized_clamped_and_stable_long(frame_type: FrameType) -> None:
|
||||
"""
|
||||
Long-frame predictor coefficients must be:
|
||||
- quantized on QUANT_STEP grid
|
||||
- clamped to [-QUANT_MAX, +QUANT_MAX]
|
||||
- stable for inverse filtering
|
||||
"""
|
||||
rng = np.random.default_rng(2)
|
||||
frame_F_in: FrameChannelF = rng.normal(size=(1024, 1)).astype(np.float64)
|
||||
|
||||
_, tns_coeffs = aac_tns(frame_F_in, frame_type)
|
||||
|
||||
a_q: MdctCoeffs = tns_coeffs[:, 0]
|
||||
_assert_quantized_and_clamped(a_q)
|
||||
assert _is_inverse_stable_from_coeffs(a_q)
|
||||
|
||||
|
||||
def test_tns_coeffs_quantized_clamped_and_stable_esh() -> None:
|
||||
"""
|
||||
ESH predictor coefficients must satisfy quantization and stability
|
||||
independently for each of the 8 short subframes.
|
||||
"""
|
||||
rng = np.random.default_rng(3)
|
||||
frame_F_in: FrameChannelF = rng.normal(size=(128, 8)).astype(np.float64)
|
||||
|
||||
_, tns_coeffs = aac_tns(frame_F_in, "ESH")
|
||||
|
||||
for j in range(8):
|
||||
a_q: MdctCoeffs = tns_coeffs[:, j]
|
||||
_assert_quantized_and_clamped(a_q)
|
||||
assert _is_inverse_stable_from_coeffs(a_q)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functional correctness (round-trip)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("frame_type", ["OLS", "LSS", "LPS"])
|
||||
def test_tns_roundtrip_long_is_close(frame_type: FrameType) -> None:
|
||||
"""
|
||||
Functional test:
|
||||
iTNS(TNS(X)) ≈ X for long frames.
|
||||
"""
|
||||
rng = np.random.default_rng(4)
|
||||
frame_F_in: FrameChannelF = rng.normal(size=(1024, 1)).astype(np.float64)
|
||||
|
||||
frame_F_tns, tns_coeffs = aac_tns(frame_F_in, frame_type)
|
||||
frame_F_hat = aac_i_tns(frame_F_tns, frame_type, tns_coeffs)
|
||||
|
||||
np.testing.assert_allclose(frame_F_hat, frame_F_in, rtol=1e-9, atol=1e-9)
|
||||
|
||||
|
||||
def test_tns_roundtrip_esh_is_close() -> None:
|
||||
"""
|
||||
Functional test:
|
||||
iTNS(TNS(X)) ≈ X for ESH frames (8 independent subframes).
|
||||
"""
|
||||
rng = np.random.default_rng(5)
|
||||
frame_F_in: FrameChannelF = rng.normal(size=(128, 8)).astype(np.float64)
|
||||
|
||||
frame_F_tns, tns_coeffs = aac_tns(frame_F_in, "ESH")
|
||||
frame_F_hat = aac_i_tns(frame_F_tns, "ESH", tns_coeffs)
|
||||
|
||||
np.testing.assert_allclose(frame_F_hat, frame_F_in, rtol=1e-9, atol=1e-9)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sanity
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def test_tns_outputs_are_finite() -> None:
|
||||
"""
|
||||
Sanity test: no NaN or inf in outputs.
|
||||
"""
|
||||
rng = np.random.default_rng(6)
|
||||
|
||||
frame_F_long: FrameChannelF = rng.normal(size=(1024, 1)).astype(np.float64)
|
||||
out_long, coeffs_long = aac_tns(frame_F_long, "OLS")
|
||||
assert np.isfinite(out_long).all()
|
||||
assert np.isfinite(coeffs_long).all()
|
||||
|
||||
frame_F_esh: FrameChannelF = rng.normal(size=(128, 8)).astype(np.float64)
|
||||
out_esh, coeffs_esh = aac_tns(frame_F_esh, "ESH")
|
||||
assert np.isfinite(out_esh).all()
|
||||
assert np.isfinite(coeffs_esh).all()
|
||||
Reference in New Issue
Block a user