Level 2: Core functionality and level_2 script wrappers added

This commit is contained in:
2026-02-08 19:43:26 +02:00
parent 399abebd2c
commit 9931e3830a
39 changed files with 4720 additions and 913 deletions
+117 -48
View File
@@ -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
+4 -17
View File
@@ -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
+98
View File
@@ -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)
+196
View File
@@ -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()