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
+88 -2
View File
@@ -30,6 +30,7 @@ import soundfile as sf
from core.aac_configuration import WIN_TYPE
from core.aac_filterbank import aac_filter_bank
from core.aac_ssc import aac_SSC
from core.aac_tns import aac_tns
from core.aac_types import *
@@ -144,8 +145,8 @@ def aac_coder_1(filename_in: Union[str, Path]) -> AACSeq1:
AACSeq1
List of encoded frames (Level 1 schema).
"""
x, fs = aac_read_wav_stereo_48k(filename_in)
_ = fs # kept for clarity; The assignment assumes 48 kHz
x, _ = aac_read_wav_stereo_48k(filename_in)
# The assignment assumes 48 kHz
hop = 1024
win = 2048
@@ -196,3 +197,88 @@ def aac_coder_1(filename_in: Union[str, Path]) -> AACSeq1:
prev_frame_type = frame_type
return aac_seq
def aac_coder_2(filename_in: Union[str, Path]) -> AACSeq2:
"""
Level-2 AAC encoder (Level 1 + TNS).
Parameters
----------
filename_in : Union[str, Path]
Input WAV filename (stereo, 48 kHz).
Returns
-------
AACSeq2
Encoded AAC sequence (Level 2 payload schema).
For each frame i:
- "frame_type": FrameType
- "win_type": WinType
- "chl"/"chr":
- "frame_F": FrameChannelF (after TNS)
- "tns_coeffs": TnsCoeffs
"""
filename_in = Path(filename_in)
x, _ = aac_read_wav_stereo_48k(filename_in)
# The assignment assumes 48 kHz
hop = 1024
win = 2048
pad_pre = np.zeros((hop, 2), dtype=np.float64)
pad_post = np.zeros((hop, 2), dtype=np.float64)
x_pad = np.vstack([pad_pre, x, pad_post])
K = int((x_pad.shape[0] - win) // hop + 1)
if K <= 0:
raise ValueError("Input too short for framing.")
aac_seq: AACSeq2 = []
prev_frame_type: FrameType = "OLS"
for i in range(K):
start = i * hop
frame_t: FrameT = x_pad[start : start + win, :]
if frame_t.shape != (win, 2):
raise ValueError("Internal framing error: frame_t has wrong shape.")
next_t = x_pad[start + hop : start + hop + win, :]
if next_t.shape[0] < win:
tail = np.zeros((win - next_t.shape[0], 2), dtype=np.float64)
next_t = np.vstack([next_t, tail])
frame_type = aac_SSC(frame_t, next_t, prev_frame_type)
# Level 1 analysis (packed stereo container)
frame_f_stereo = aac_filter_bank(frame_t, frame_type, WIN_TYPE)
# Unpack to per-channel (as you already do in Level 1)
if frame_type == "ESH":
chl_f = np.empty((128, 8), dtype=np.float64)
chr_f = np.empty((128, 8), dtype=np.float64)
for j in range(8):
chl_f[:, j] = frame_f_stereo[:, 2 * j + 0]
chr_f[:, j] = frame_f_stereo[:, 2 * j + 1]
else:
chl_f = frame_f_stereo[:, 0:1].astype(np.float64, copy=False)
chr_f = frame_f_stereo[:, 1:2].astype(np.float64, copy=False)
# Level 2: apply TNS per channel
chl_f_tns, chl_tns_coeffs = aac_tns(chl_f, frame_type)
chr_f_tns, chr_tns_coeffs = aac_tns(chr_f, frame_type)
aac_seq.append(
{
"frame_type": frame_type,
"win_type": WIN_TYPE,
"chl": {"frame_F": chl_f_tns, "tns_coeffs": chl_tns_coeffs},
"chr": {"frame_F": chr_f_tns, "tns_coeffs": chr_tns_coeffs},
}
)
prev_frame_type = frame_type
return aac_seq
+10 -1
View File
@@ -17,6 +17,15 @@ from __future__ import annotations
# Imports
from core.aac_types import WinType
# Filterbank
# ------------------------------------------------------------
# Window type
# Options: "SIN", "KBD"
WIN_TYPE: WinType = "SIN"
WIN_TYPE: WinType = "SIN"
# TNS
# ------------------------------------------------------------
PRED_ORDER = 4
QUANT_STEP = 0.1
QUANT_MAX = 0.7 # 4-bit symmetric with step 0.1 -> clamp to [-0.7, +0.7]
+91
View File
@@ -28,6 +28,7 @@ from typing import Union
import soundfile as sf
from core.aac_filterbank import aac_i_filter_bank
from core.aac_tns import aac_i_tns
from core.aac_types import *
@@ -164,3 +165,93 @@ def aac_decoder_1(aac_seq_1: AACSeq1, filename_out: Union[str, Path]) -> StereoS
sf.write(str(filename_out), y, 48000)
return y
def aac_decoder_2(aac_seq_2: AACSeq2, filename_out: Union[str, Path]) -> StereoSignal:
"""
Level-2 AAC decoder (inverse of aac_coder_2).
Behavior matches Level 1 decoder pipeline, with additional iTNS stage:
- Per frame/channel: inverse TNS using stored coefficients
- Re-pack to stereo frame_F
- IMDCT + windowing
- Overlap-add over frames
- Remove Level-1 padding (hop samples start/end)
- Write output WAV (48 kHz)
Parameters
----------
aac_seq_2 : AACSeq2
Encoded sequence as produced by aac_coder_2().
filename_out : Union[str, Path]
Output WAV filename.
Returns
-------
StereoSignal
Decoded audio samples (time-domain), stereo, shape (N, 2), dtype float64.
"""
filename_out = Path(filename_out)
hop = 1024
win = 2048
K = len(aac_seq_2)
if K <= 0:
raise ValueError("aac_seq_2 must contain at least one frame.")
n_pad = (K - 1) * hop + win
y_pad = np.zeros((n_pad, 2), dtype=np.float64)
for i, fr in enumerate(aac_seq_2):
frame_type: FrameType = fr["frame_type"]
win_type: WinType = fr["win_type"]
chl_f_tns = np.asarray(fr["chl"]["frame_F"], dtype=np.float64)
chr_f_tns = np.asarray(fr["chr"]["frame_F"], dtype=np.float64)
chl_coeffs = np.asarray(fr["chl"]["tns_coeffs"], dtype=np.float64)
chr_coeffs = np.asarray(fr["chr"]["tns_coeffs"], dtype=np.float64)
# Inverse TNS per channel
chl_f = aac_i_tns(chl_f_tns, frame_type, chl_coeffs)
chr_f = aac_i_tns(chr_f_tns, frame_type, chr_coeffs)
# Re-pack to the stereo container expected by aac_i_filter_bank
if frame_type == "ESH":
if chl_f.shape != (128, 8) or chr_f.shape != (128, 8):
raise ValueError("ESH channel frame_F must have shape (128, 8).")
frame_f: FrameF = np.empty((128, 16), dtype=np.float64)
for j in range(8):
frame_f[:, 2 * j + 0] = chl_f[:, j]
frame_f[:, 2 * j + 1] = chr_f[:, j]
else:
# Accept either (1024,1) or (1024,) from your internal convention.
if chl_f.shape == (1024,):
chl_col = chl_f.reshape(1024, 1)
elif chl_f.shape == (1024, 1):
chl_col = chl_f
else:
raise ValueError("Non-ESH left channel frame_F must be shape (1024,) or (1024, 1).")
if chr_f.shape == (1024,):
chr_col = chr_f.reshape(1024, 1)
elif chr_f.shape == (1024, 1):
chr_col = chr_f
else:
raise ValueError("Non-ESH right channel frame_F must be shape (1024,) or (1024, 1).")
frame_f = np.empty((1024, 2), dtype=np.float64)
frame_f[:, 0] = chl_col[:, 0]
frame_f[:, 1] = chr_col[:, 0]
frame_t_hat: FrameT = aac_i_filter_bank(frame_f, frame_type, win_type)
start = i * hop
y_pad[start : start + win, :] += frame_t_hat
y = aac_remove_padding(y_pad, hop=hop)
sf.write(str(filename_out), y, 48000)
return y
+60
View File
@@ -0,0 +1,60 @@
# ------------------------------------------------------------
# AAC Coder/Decoder - SNR dB calculator
#
# Multimedia course at Aristotle University of
# Thessaloniki (AUTh)
#
# Author:
# Christos Choutouridis (ΑΕΜ 8997)
# cchoutou@ece.auth.gr
#
# Description:
# This module implements SNR calculation in dB
# ------------------------------------------------------------
from __future__ import annotations
from core.aac_types import StereoSignal
import numpy as np
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 stereo stream.
x_hat : StereoSignal
Reconstructed stereo stream.
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)
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))
+97 -8
View File
@@ -10,7 +10,6 @@
#
# Description:
# This module implements Public Type aliases
#
# ------------------------------------------------------------
from __future__ import annotations
@@ -39,7 +38,7 @@ Window type codes (AAC):
"""
ChannelKey: TypeAlias = Literal["chl", "chr"]
"""Channel dictionary keys used in Level 1 payloads."""
"""Channel dictionary keys used in Level payloads."""
# -----------------------------------------------------------------------------
@@ -105,6 +104,40 @@ Examples:
dtype: float64
"""
MdctFrameChannel: TypeAlias = FloatArray
"""
Per-channel MDCT container used in Level-1/2 sequences.
Typical shapes:
- If frame_type in {"OLS","LSS","LPS"}: (1024, 1) or (1024,)
- If frame_type == "ESH": (128, 8) (8 short subframes for one channel)
dtype: float64
Notes
-----
Some parts of the assignment store long-frame coefficients as a column vector
(1024, 1) to match MATLAB conventions. Internally you may also use (1024,)
when convenient, but the semantic meaning is identical.
"""
TnsCoeffs: TypeAlias = FloatArray
"""
Quantized TNS predictor coefficients (one channel).
Typical shapes (Level 2):
- If frame_type == "ESH": (4, 8) (order p=4 for each of the 8 short subframes)
- Else: (4, 1) (order p=4 for the long frame)
dtype: float64
Notes
-----
The assignment uses a 4-bit uniform symmetric quantizer with step size 0.1.
We store the quantized coefficient values as float64 (typically multiples of 0.1)
to keep the pipeline simple and readable.
"""
FrameT: TypeAlias = FloatArray
"""
@@ -142,17 +175,23 @@ Rationale for ESH (128, 16):
dtype: float64
"""
FrameChannelF: TypeAlias = FloatArray
FrameChannelF: TypeAlias = MdctFrameChannel
"""
Frequency-domain single-channel frame (MDCT coefficients).
Frequency-domain single-channel MDCT coefficients.
Typical shapes (Level 1):
- If frame_type in {"OLS","LSS","LPS"}: (1024,)
- If frame_type == "ESH": (128, 8) (8 short subframes for one channel)
Typical shapes (Level 1/2):
- If frame_type in {"OLS","LSS","LPS"}: (1024, 1) or (1024,)
- If frame_type == "ESH": (128, 8)
dtype: float64
"""
BandRanges: TypeAlias = list[tuple[int, int]]
"""
Bark-band index ranges [start, end] (inclusive) for MDCT lines.
Used by TNS to map MDCT indices k to Bark bands.
"""
# -----------------------------------------------------------------------------
# Level 1 AAC sequence payload types
@@ -168,7 +207,7 @@ class AACChannelFrameF(TypedDict):
The MDCT coefficients for ONE channel.
Typical shapes:
- ESH: (128, 8) (8 short subframes)
- else: (1024, )
- else: (1024, 1) or (1024,)
"""
frame_F: FrameChannelF
@@ -191,3 +230,53 @@ List of length K (K = number of frames).
Each element is a dict with keys:
- "frame_type", "win_type", "chl", "chr"
"""
# -----------------------------------------------------------------------------
# Level 2 AAC sequence payload types (TNS)
# -----------------------------------------------------------------------------
class AACChannelFrameF2(TypedDict):
"""
Per-channel payload for aac_seq_2[i]["chl"] or ["chr"] (Level 2).
Keys
----
frame_F:
The TNS-processed MDCT coefficients for ONE channel.
Typical shapes:
- ESH: (128, 8)
- else: (1024, 1) or (1024,)
tns_coeffs:
Quantized TNS predictor coefficients for ONE channel.
Typical shapes:
- ESH: (PRED_ORDER, 8)
- else: (PRED_ORDER, 1)
"""
frame_F: FrameChannelF
tns_coeffs: TnsCoeffs
class AACSeq2Frame(TypedDict):
"""
One frame dictionary element of aac_seq_2 (Level 2).
"""
frame_type: FrameType
win_type: WinType
chl: AACChannelFrameF2
chr: AACChannelFrameF2
AACSeq2: TypeAlias = List[AACSeq2Frame]
"""
AAC sequence for Level 2:
List of length K (K = number of frames).
Each element is a dict with keys:
- "frame_type", "win_type", "chl", "chr"
Level 2 adds:
- per-channel "tns_coeffs"
and stores:
- per-channel "frame_F" after applying TNS.
"""
-234
View File
@@ -1,234 +0,0 @@
# ------------------------------------------------------------
# AAC Coder/Decoder - Sequence Segmentation Control Tests
#
# Multimedia course at Aristotle University of
# Thessaloniki (AUTh)
#
# Author:
# Christos Choutouridis (ΑΕΜ 8997)
# cchoutou@ece.auth.gr
#
# Description:
# Tests for Sequence Segmentation Control module (SSC).
# ------------------------------------------------------------
from __future__ import annotations
import numpy as np
from core.aac_ssc import aac_SSC
from core.aac_types import FrameT
# -----------------------------------------------------------------------------
# Helper fixtures for SSC
# -----------------------------------------------------------------------------
def _next_frame_no_attack() -> FrameT:
"""
Build a next_frame_T that must NOT trigger ESH detection.
Uses exact zeros so all segment energies are zero and the condition
s[l] > 1e-3 cannot hold for any l.
"""
return np.zeros((2048, 2), dtype=np.float64)
def _next_frame_strong_attack(
*,
attack_left: bool,
attack_right: bool,
segment_l: int = 4,
baseline: float = 1e-6,
burst_amp: float = 1.0,
) -> FrameT:
"""
Build a next_frame_T (2048x2) that should trigger ESH detection on selected channels.
Attack criterion (spec):
Attack exists if there exists l in {1..7} such that:
s[l] > 1e-3 and ds[l] > 10,
where s[l] is the energy of segment l (length 128) after high-pass filtering,
and ds[l] = s[l] / s[l-1].
Construction:
- A small baseline is added everywhere to avoid relying on the epsilon guard in ds,
keeping ds behavior stable/reproducible.
- A strong burst is added inside a chosen segment l in 1..7.
"""
if not (1 <= segment_l <= 7):
raise ValueError(f"segment_l must be in [1, 7], got {segment_l}.")
x = np.full((2048, 2), baseline, dtype=np.float64)
a = segment_l * 128
b = (segment_l + 1) * 128
if attack_left:
x[a:b, 0] += burst_amp
if attack_right:
x[a:b, 1] += burst_amp
return x
def _next_frame_below_s_threshold(
*,
left: bool,
right: bool,
segment_l: int = 4,
impulse_amp: float = 0.01,
) -> FrameT:
"""
Construct a next_frame_T where s[l] is below 1e-3, so ESH must NOT be triggered,
even if the ratio ds[l] could be large.
We place a single impulse of amplitude 'impulse_amp' inside one segment.
Approx. segment energy: s[l] ~= impulse_amp^2.
Example:
impulse_amp = 0.01 => s[l] ~= 1e-4 < 1e-3
"""
if not (1 <= segment_l <= 7):
raise ValueError(f"segment_l must be in [1, 7], got {segment_l}.")
x = np.zeros((2048, 2), dtype=np.float64)
idx = segment_l * 128 + 10 # inside segment l
if left:
x[idx, 0] = impulse_amp
if right:
x[idx, 1] = impulse_amp
return x
# -----------------------------------------------------------------------------
# 1) Fixed/mandatory cases (prev frame type forces current type)
# -----------------------------------------------------------------------------
def test_ssc_fixed_cases_prev_lss_and_lps() -> None:
"""
Spec:
- If prev was LSS => current MUST be ESH
- If prev was LPS => current MUST be OLS
independent of attack detection on (i+1).
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next_attack = _next_frame_strong_attack(attack_left=True, attack_right=True)
out1 = aac_SSC(frame_t, next_attack, "LSS")
assert out1 == "ESH"
out2 = aac_SSC(frame_t, next_attack, "LPS")
assert out2 == "OLS"
# -----------------------------------------------------------------------------
# 2) Cases requiring next-frame ESH prediction (attack computation)
# -----------------------------------------------------------------------------
def test_prev_ols_next_not_esh_returns_ols() -> None:
"""
If prev=OLS, current is:
- LSS iff (i+1) is predicted ESH
- else OLS
Here: no attack => expect OLS.
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next_t = _next_frame_no_attack()
out = aac_SSC(frame_t, next_t, "OLS")
assert out == "OLS"
def test_prev_ols_next_esh_both_channels_returns_lss() -> None:
"""
prev=OLS and next predicted ESH for both channels:
per-channel: LSS, LSS
merged: LSS
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next_t = _next_frame_strong_attack(attack_left=True, attack_right=True)
out = aac_SSC(frame_t, next_t, "OLS")
assert out == "LSS"
def test_prev_ols_next_esh_one_channel_returns_lss() -> None:
"""
prev=OLS:
- one channel predicts ESH => LSS
- other channel predicts not ESH => OLS
Merge table: OLS + LSS => LSS (either side).
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next1_t = _next_frame_strong_attack(attack_left=True, attack_right=False)
out1 = aac_SSC(frame_t, next1_t, "OLS")
assert out1 == "LSS"
next2_t = _next_frame_strong_attack(attack_left=False, attack_right=True)
out2 = aac_SSC(frame_t, next2_t, "OLS")
assert out2 == "LSS"
def test_prev_esh_next_esh_both_channels_returns_esh() -> None:
"""
prev=ESH and next predicted ESH for both channels:
per-channel: ESH, ESH
merged: ESH
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next_t = _next_frame_strong_attack(attack_left=True, attack_right=True)
out = aac_SSC(frame_t, next_t, "ESH")
assert out == "ESH"
def test_prev_esh_next_not_esh_both_channels_returns_lps() -> None:
"""
prev=ESH and next not predicted ESH for both channels:
per-channel: LPS, LPS
merged: LPS
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next_t = _next_frame_no_attack()
out = aac_SSC(frame_t, next_t, "ESH")
assert out == "LPS"
def test_prev_esh_next_esh_one_channel_merged_is_esh() -> None:
"""
prev=ESH:
- one channel predicts ESH => ESH
- other channel predicts not ESH => LPS
Merge table: ESH + LPS => ESH (either side).
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next1_t = _next_frame_strong_attack(attack_left=True, attack_right=False)
out1 = aac_SSC(frame_t, next1_t, "ESH")
assert out1 == "ESH"
next2_t = _next_frame_strong_attack(attack_left=False, attack_right=True)
out2 = aac_SSC(frame_t, next2_t, "ESH")
assert out2 == "ESH"
def test_threshold_s_must_exceed_1e_3() -> None:
"""
Spec: next frame is predicted ESH only if:
s[l] > 1e-3 AND ds[l] > 10
for some l in 1..7.
This test checks the necessity of the s[l] threshold:
- Create a frame with s[l] ~= 1e-4 < 1e-3 (single impulse with amp 0.01).
- Expect: not classified as ESH -> for prev=OLS return OLS.
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
next_t = _next_frame_below_s_threshold(left=True, right=True, impulse_amp=0.01)
out = aac_SSC(frame_t, next_t, "OLS")
assert out == "OLS"
@@ -1,156 +0,0 @@
# ------------------------------------------------------------
# AAC Coder/Decoder - AAC Coder/DecoderTests
#
# Multimedia course at Aristotle University of
# Thessaloniki (AUTh)
#
# Author:
# Christos Choutouridis (ΑΕΜ 8997)
# cchoutou@ece.auth.gr
#
# Description:
# Tests for AAC Coder/Decoder module.
# ------------------------------------------------------------
from __future__ import annotations
from pathlib import Path
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_types import *
# 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:
"""
Create a temporary 48 kHz stereo WAV with random samples.
"""
rng = np.random.default_rng(123)
fs = 48000
# ~1 second of audio (kept small for test speed).
n = fs
x: StereoSignal = rng.normal(size=(n, 2)).astype(np.float64)
wav_path = tmp_path / "in.wav"
sf.write(str(wav_path), x, fs)
return wav_path
def test_aac_coder_seq_schema_and_shapes(tmp_stereo_wav: Path) -> None:
"""
Module-level contract test:
Ensure aac_seq_1 follows the expected schema and per-frame shapes.
"""
aac_seq: AACSeq1 = aac_coder_1(tmp_stereo_wav)
assert isinstance(aac_seq, list)
assert len(aac_seq) > 0
for fr in aac_seq:
assert isinstance(fr, dict)
# Required keys
assert "frame_type" in fr
assert "win_type" in fr
assert "chl" in fr
assert "chr" in fr
frame_type = fr["frame_type"]
win_type = fr["win_type"]
assert frame_type in ("OLS", "LSS", "ESH", "LPS")
assert win_type in ("SIN", "KBD")
assert isinstance(fr["chl"], dict)
assert isinstance(fr["chr"], dict)
assert "frame_F" in fr["chl"]
assert "frame_F" in fr["chr"]
chl_f = np.asarray(fr["chl"]["frame_F"], dtype=np.float64)
chr_f = np.asarray(fr["chr"]["frame_F"], dtype=np.float64)
if frame_type == "ESH":
assert chl_f.shape == (128, 8)
assert chr_f.shape == (128, 8)
else:
assert chl_f.shape == (1024, 1)
assert chr_f.shape == (1024, 1)
def test_end_to_end_aac_coder_decoder_high_snr(tmp_stereo_wav: Path, tmp_path: Path) -> None:
"""
End-to-end test:
Encode + decode and check SNR is very high (numerical-noise only).
The threshold is intentionally loose to avoid fragility across platforms/BLAS.
"""
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.wav"
aac_seq = aac_coder_1(tmp_stereo_wav)
x_hat: StereoSignal = aac_decoder_1(aac_seq, out_wav)
# Basic sanity: output file exists and is readable
assert out_wav.exists()
x_hat_file, fs_hat = sf.read(str(out_wav), always_2d=True)
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)
assert snr > 80.0
@@ -1,269 +0,0 @@
# ------------------------------------------------------------
# AAC Coder/Decoder - Filterbank Tests
#
# Multimedia course at Aristotle University of
# Thessaloniki (AUTh)
#
# Author:
# Christos Choutouridis (ΑΕΜ 8997)
# cchoutou@ece.auth.gr
#
# Description:
# Tests for Filterbank module.
# ------------------------------------------------------------
from __future__ import annotations
from typing import Sequence
import pytest
from core.aac_filterbank import aac_filter_bank, aac_i_filter_bank
from core.aac_types import *
# Helper fixtures for filterbank
# -----------------------------------------------------------------------------
def _ola_reconstruct(x: StereoSignal, frame_types: Sequence[FrameType], win_type: WinType) -> StereoSignal:
"""
Analyze-synthesize each frame and overlap-add with hop=1024.
Parameters
----------
x : StereoSignal
Input stereo stream, expected shape (N, 2).
frame_types : Sequence[FrameType]
Length K sequence of frame types for frames starting at i*1024.
win_type : WinType
Window type ("SIN" or "KBD").
Returns
-------
StereoSignal
Reconstructed stereo stream, same shape as x (N, 2).
"""
hop = 1024
win = 2048
K = len(frame_types)
y: StereoSignal = np.zeros_like(x, dtype=np.float64)
for i in range(K):
start = i * hop
frame_t: FrameT = x[start:start + win, :]
frame_f: FrameF = aac_filter_bank(frame_t, frame_types[i], win_type)
frame_t_hat: FrameT = aac_i_filter_bank(frame_f, frame_types[i], win_type)
y[start:start + win, :] += frame_t_hat
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
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
@pytest.mark.parametrize("frame_type", ["OLS", "LSS", "LPS"])
def test_filterbank_shapes_long_sequences(frame_type: FrameType, win_type: WinType) -> None:
"""
Contract test: for OLS/LSS/LPS, aac_filter_bank returns shape (1024, 2).
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
frame_f = aac_filter_bank(frame_t, frame_type, win_type)
assert frame_f.shape == (1024, 2)
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_filterbank_shapes_esh(win_type: WinType) -> None:
"""
Contract test: for ESH, aac_filter_bank returns shape (128, 16).
"""
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
frame_f = aac_filter_bank(frame_t, "ESH", win_type)
assert frame_f.shape == (128, 16)
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_filterbank_channel_isolation_long_sequences(win_type: WinType) -> None:
"""
Behavior test: for OLS (representative long-sequence), channels are independent.
If right channel is zero and left is random, right spectrum should be near zero.
"""
rng = np.random.default_rng(0)
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
frame_t[:, 0] = rng.normal(size=2048)
frame_f = aac_filter_bank(frame_t, "OLS", win_type)
assert np.max(np.abs(frame_f[:, 1])) < 1e-9
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_filterbank_channel_isolation_esh(win_type: WinType) -> None:
"""
Behavior test: for ESH, channels are independent.
If right channel is zero and left is random, all odd columns (right) should be near zero.
"""
rng = np.random.default_rng(1)
frame_t: FrameT = np.zeros((2048, 2), dtype=np.float64)
frame_t[:, 0] = rng.normal(size=2048)
frame_f = aac_filter_bank(frame_t, "ESH", win_type)
right_cols = frame_f[:, 1::2] # columns 1,3,5,...,15
assert np.max(np.abs(right_cols)) < 1e-9
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_filterbank_esh_ignores_outer_regions(win_type: WinType) -> None:
"""
Spec-driven behavior test:
ESH uses only the central region [448, 1600), split into 8 overlapping
windows of length 256 with 50% overlap.
Therefore, changing samples outside [448, 1600) must not affect the output.
"""
rng = np.random.default_rng(2)
frame_a: FrameT = np.zeros((2048, 2), dtype=np.float64)
frame_b: FrameT = np.zeros((2048, 2), dtype=np.float64)
center = rng.normal(size=(1152, 2))
frame_a[448:1600, :] = center
frame_b[448:1600, :] = center
frame_b[0:448, :] = rng.normal(size=(448, 2))
frame_b[1600:2048, :] = rng.normal(size=(448, 2))
fa = aac_filter_bank(frame_a, "ESH", win_type)
fb = aac_filter_bank(frame_b, "ESH", win_type)
# Use a tiny tolerance to avoid flaky failures due to floating-point minutiae.
np.testing.assert_allclose(fa, fb, rtol=0.0, atol=1e-12)
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_filterbank_output_is_finite(win_type: WinType) -> None:
"""
Sanity test: output must not contain NaN or inf for representative cases.
"""
rng = np.random.default_rng(3)
frame_t: FrameT = rng.normal(size=(2048, 2)).astype(np.float64)
for frame_type in ("OLS", "LSS", "ESH", "LPS"):
frame_f = aac_filter_bank(frame_t, frame_type, win_type)
assert np.isfinite(frame_f).all()
# -----------------------------------------------------------------------------
# Reverse i_filterbank tests
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_ifilterbank_shapes_long_sequences(win_type: WinType) -> None:
"""
Contract test: for OLS/LSS/LPS, aac_i_filter_bank returns shape (2048, 2).
"""
frame_f: FrameF = np.zeros((1024, 2), dtype=np.float64)
for frame_type in ("OLS", "LSS", "LPS"):
frame_t = aac_i_filter_bank(frame_f, frame_type, win_type)
assert frame_t.shape == (2048, 2)
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_ifilterbank_shapes_esh(win_type: WinType) -> None:
"""
Contract test: for ESH, aac_i_filter_bank returns shape (2048, 2).
"""
frame_f: FrameF = np.zeros((128, 16), dtype=np.float64)
frame_t = aac_i_filter_bank(frame_f, "ESH", win_type)
assert frame_t.shape == (2048, 2)
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_roundtrip_per_frame_is_finite(win_type: WinType) -> None:
"""
Sanity test: per-frame analysis+synthesis must produce finite outputs.
"""
rng = np.random.default_rng(0)
frame_t: FrameT = rng.normal(size=(2048, 2)).astype(np.float64)
for frame_type in ("OLS", "LSS", "ESH", "LPS"):
frame_f = aac_filter_bank(frame_t, frame_type, win_type)
frame_t_hat = aac_i_filter_bank(frame_f, frame_type, win_type)
assert np.isfinite(frame_t_hat).all()
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_ola_reconstruction_ols_high_snr(win_type: WinType) -> None:
"""
Module-level test:
OLS analysis+synthesis with hop=1024 must reconstruct with high SNR
in the steady-state region.
"""
rng = np.random.default_rng(1)
K = 6
N = 1024 * (K + 1)
x: StereoSignal = rng.normal(size=(N, 2)).astype(np.float64)
y = _ola_reconstruct(x, ["OLS"] * K, win_type)
a = 1024
b = N - 1024
snr = _snr_db(x[a:b, :], y[a:b, :])
assert snr > 50.0
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_ola_reconstruction_esh_high_snr(win_type: WinType) -> None:
"""
Module-level test:
ESH analysis+synthesis with hop=1024 must reconstruct with high SNR
in the steady-state region.
"""
rng = np.random.default_rng(2)
K = 6
N = 1024 * (K + 1)
x: StereoSignal = rng.normal(size=(N, 2)).astype(np.float64)
y = _ola_reconstruct(x, ["ESH"] * K, win_type)
a = 1024
b = N - 1024
snr = _snr_db(x[a:b, :], y[a:b, :])
assert snr > 45.0
@pytest.mark.parametrize("win_type", ["SIN", "KBD"])
def test_ola_reconstruction_transition_sequence(win_type: WinType) -> None:
"""
Transition sequence test matching the windowing logic:
OLS -> LSS -> ESH -> LPS -> OLS -> OLS
"""
rng = np.random.default_rng(3)
frame_types: list[FrameType] = ["OLS", "LSS", "ESH", "LPS", "OLS", "OLS"]
K = len(frame_types)
N = 1024 * (K + 1)
x: StereoSignal = rng.normal(size=(N, 2)).astype(np.float64)
y = _ola_reconstruct(x, frame_types, win_type)
a = 1024
b = N - 1024
snr = _snr_db(x[a:b, :], y[a:b, :])
assert snr > 40.0
@@ -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()
+8 -50
View File
@@ -22,13 +22,13 @@ from __future__ import annotations
from pathlib import Path
from typing import Union
import numpy as np
import soundfile as sf
from core.aac_types import AACSeq1, StereoSignal
from core.aac_coder import aac_coder_1 as core_aac_coder_1
from core.aac_coder import aac_read_wav_stereo_48k
from core.aac_decoder import aac_decoder_1 as core_aac_decoder_1
from core.aac_snr_db import snr_db
# -----------------------------------------------------------------------------
@@ -80,50 +80,6 @@ def aac_decoder_1(aac_seq_1: AACSeq1, filename_out: Union[str, Path]) -> StereoS
# Demo (Level 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 stereo stream.
x_hat : StereoSignal
Reconstructed stereo stream.
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)
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))
def demo_aac_1(filename_in: Union[str, Path], filename_out: Union[str, Path]) -> float:
"""
Demonstration for the Level-1 codec.
@@ -158,12 +114,11 @@ def demo_aac_1(filename_in: Union[str, Path], filename_out: Union[str, Path]) ->
x_hat = aac_decoder_1(aac_seq_1, filename_out)
# Optional sanity: ensure output file exists and is readable
x_hat_file, fs_hat = sf.read(str(filename_out), always_2d=True)
_ = x_hat_file
_, fs_hat = sf.read(str(filename_out), always_2d=True)
if int(fs_hat) != 48000:
raise ValueError("Decoded output sampling rate must be 48 kHz.")
return _snr_db(x_ref, x_hat)
return snr_db(x_ref, x_hat)
# -----------------------------------------------------------------------------
@@ -172,11 +127,14 @@ def demo_aac_1(filename_in: Union[str, Path], filename_out: Union[str, Path]) ->
if __name__ == "__main__":
# Example:
# python -m level_1.level_1 input.wav output.wav
# cd level_1
# python -m level_1 input.wav output.wav
# or
# python -m level_1 material/LicorDeCalandraca.wav LicorDeCalandraca_out.wav
import sys
if len(sys.argv) != 3:
raise SystemExit("Usage: python -m level_1.level_1 <input.wav> <output.wav>")
raise SystemExit("Usage: python -m level_1 <input.wav> <output.wav>")
in_wav = Path(sys.argv[1])
out_wav = Path(sys.argv[2])
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+400
View File
@@ -0,0 +1,400 @@
import numpy as np
import scipy.io as sio
import os
# ------------------ LOAD LUT ------------------
def load_LUT(mat_filename=None):
"""
Loads the list of Huffman Codebooks (LUTs)
Returns:
huffLUT : list (index 1..11 used, index 0 unused)
"""
if mat_filename is None:
current_dir = os.path.dirname(os.path.abspath(__file__))
mat_filename = os.path.join(current_dir, "huffCodebooks.mat")
mat = sio.loadmat(mat_filename)
huffCodebooks_raw = mat['huffCodebooks'].squeeze()
huffCodebooks = []
for i in range(11):
huffCodebooks.append(np.array(huffCodebooks_raw[i]))
# Build inverse VLC tables
invTable = [None] * 11
for i in range(11):
h = huffCodebooks[i][:, 2].astype(int) # column 3
hlength = huffCodebooks[i][:, 1].astype(int) # column 2
hbin = []
for j in range(len(h)):
hbin.append(format(h[j], f'0{hlength[j]}b'))
invTable[i] = vlc_table(hbin)
# Build Huffman LUT dicts
huffLUT = [None] * 12 # index 0 unused
params = [
(4, 1, True),
(4, 1, True),
(4, 2, False),
(4, 2, False),
(2, 4, True),
(2, 4, True),
(2, 7, False),
(2, 7, False),
(2, 12, False),
(2, 12, False),
(2, 16, False),
]
for i, (nTupleSize, maxAbs, signed) in enumerate(params, start=1):
huffLUT[i] = {
'LUT': huffCodebooks[i-1],
'invTable': invTable[i-1],
'codebook': i,
'nTupleSize': nTupleSize,
'maxAbsCodeVal': maxAbs,
'signedValues': signed
}
return huffLUT
def vlc_table(code_array):
"""
codeArray: list of strings, each string is a Huffman codeword (e.g. '0101')
returns:
h : NumPy array of shape (num_nodes, 3)
columns:
[ next_if_0 , next_if_1 , symbol_index ]
"""
h = np.zeros((1, 3), dtype=int)
for code_index, code in enumerate(code_array, start=1):
word = [int(bit) for bit in code]
h_index = 0
for bit in word:
k = bit
next_node = h[h_index, k]
if next_node == 0:
h = np.vstack([h, [0, 0, 0]])
new_index = h.shape[0] - 1
h[h_index, k] = new_index
h_index = new_index
else:
h_index = next_node
h[h_index, 2] = code_index
return h
# ------------------ ENCODE ------------------
def encode_huff(coeff_sec, huff_LUT_list, force_codebook = None):
"""
Huffman-encode a sequence of quantized coefficients.
This function selects the appropriate Huffman codebook based on the
maximum absolute value of the input coefficients, encodes the coefficients
into a binary Huffman bitstream, and returns both the bitstream and the
selected codebook index.
This is the Python equivalent of the MATLAB `encodeHuff.m` function used
in audio/image coding (e.g., scale factor band encoding). The input
coefficient sequence is grouped into fixed-size tuples as defined by
the chosen Huffman LUT. Zero-padding may be applied internally.
Parameters
----------
coeff_sec : array_like of int
1-D array of quantized integer coefficients to encode.
Typically corresponds to a "section" or scale-factor band.
huff_LUT_list : list
List of Huffman lookup-table dictionaries as returned by `loadLUT()`.
Index 1..11 correspond to valid Huffman codebooks.
Index 0 is unused.
Returns
-------
huffSec : str
Huffman-encoded bitstream represented as a string of '0' and '1'
characters.
huffCodebook : int
Index (1..11) of the Huffman codebook used for encoding.
A value of 0 indicates a special all-zero section.
"""
if force_codebook is not None:
return huff_LUT_code_1(huff_LUT_list[force_codebook], coeff_sec)
maxAbsVal = np.max(np.abs(coeff_sec))
if maxAbsVal == 0:
huffCodebook = 0
huffSec = huff_LUT_code_0()
elif maxAbsVal == 1:
candidates = [1, 2]
huffSec1 = huff_LUT_code_1(huff_LUT_list[candidates[0]], coeff_sec)
huffSec2 = huff_LUT_code_1(huff_LUT_list[candidates[1]], coeff_sec)
if len(huffSec1) <= len(huffSec2):
huffSec = huffSec1
huffCodebook = candidates[0]
else:
huffSec = huffSec2
huffCodebook = candidates[1]
elif maxAbsVal == 2:
candidates = [3, 4]
huffSec1 = huff_LUT_code_1(huff_LUT_list[candidates[0]], coeff_sec)
huffSec2 = huff_LUT_code_1(huff_LUT_list[candidates[1]], coeff_sec)
if len(huffSec1) <= len(huffSec2):
huffSec = huffSec1
huffCodebook = candidates[0]
else:
huffSec = huffSec2
huffCodebook = candidates[1]
elif maxAbsVal in (3, 4):
candidates = [5, 6]
huffSec1 = huff_LUT_code_1(huff_LUT_list[candidates[0]], coeff_sec)
huffSec2 = huff_LUT_code_1(huff_LUT_list[candidates[1]], coeff_sec)
if len(huffSec1) <= len(huffSec2):
huffSec = huffSec1
huffCodebook = candidates[0]
else:
huffSec = huffSec2
huffCodebook = candidates[1]
elif maxAbsVal in (5, 6, 7):
candidates = [7, 8]
huffSec1 = huff_LUT_code_1(huff_LUT_list[candidates[0]], coeff_sec)
huffSec2 = huff_LUT_code_1(huff_LUT_list[candidates[1]], coeff_sec)
if len(huffSec1) <= len(huffSec2):
huffSec = huffSec1
huffCodebook = candidates[0]
else:
huffSec = huffSec2
huffCodebook = candidates[1]
elif maxAbsVal in (8, 9, 10, 11, 12):
candidates = [9, 10]
huffSec1 = huff_LUT_code_1(huff_LUT_list[candidates[0]], coeff_sec)
huffSec2 = huff_LUT_code_1(huff_LUT_list[candidates[1]], coeff_sec)
if len(huffSec1) <= len(huffSec2):
huffSec = huffSec1
huffCodebook = candidates[0]
else:
huffSec = huffSec2
huffCodebook = candidates[1]
elif maxAbsVal in (13, 14, 15):
huffCodebook = 11
huffSec = huff_LUT_code_1(huff_LUT_list[huffCodebook], coeff_sec)
else:
huffCodebook = 11
huffSec = huff_LUT_code_ESC(huff_LUT_list[huffCodebook], coeff_sec)
return huffSec, huffCodebook
def huff_LUT_code_1(huff_LUT, coeff_sec):
LUT = huff_LUT['LUT']
nTupleSize = huff_LUT['nTupleSize']
maxAbsCodeVal = huff_LUT['maxAbsCodeVal']
signedValues = huff_LUT['signedValues']
numTuples = int(np.ceil(len(coeff_sec) / nTupleSize))
if signedValues:
coeff = coeff_sec + maxAbsCodeVal
base = 2 * maxAbsCodeVal + 1
else:
coeff = coeff_sec
base = maxAbsCodeVal + 1
coeffPad = np.zeros(numTuples * nTupleSize, dtype=int)
coeffPad[:len(coeff)] = coeff
huffSec = []
powers = base ** np.arange(nTupleSize - 1, -1, -1)
for i in range(numTuples):
nTuple = coeffPad[i*nTupleSize:(i+1)*nTupleSize]
huffIndex = int(np.abs(nTuple) @ powers)
hexVal = LUT[huffIndex, 2]
huffLen = LUT[huffIndex, 1]
bits = format(int(hexVal), f'0{int(huffLen)}b')
if signedValues:
huffSec.append(bits)
else:
signBits = ''.join('1' if v < 0 else '0' for v in nTuple)
huffSec.append(bits + signBits)
return ''.join(huffSec)
def huff_LUT_code_0():
return ''
def huff_LUT_code_ESC(huff_LUT, coeff_sec):
LUT = huff_LUT['LUT']
nTupleSize = huff_LUT['nTupleSize']
maxAbsCodeVal = huff_LUT['maxAbsCodeVal']
numTuples = int(np.ceil(len(coeff_sec) / nTupleSize))
base = maxAbsCodeVal + 1
coeffPad = np.zeros(numTuples * nTupleSize, dtype=int)
coeffPad[:len(coeff_sec)] = coeff_sec
huffSec = []
powers = base ** np.arange(nTupleSize - 1, -1, -1)
for i in range(numTuples):
nTuple = coeffPad[i*nTupleSize:(i+1)*nTupleSize]
lnTuple = nTuple.astype(float)
lnTuple[lnTuple == 0] = np.finfo(float).eps
N4 = np.maximum(0, np.floor(np.log2(np.abs(lnTuple))).astype(int))
N = np.maximum(0, N4 - 4)
esc = np.abs(nTuple) > 15
nTupleESC = nTuple.copy()
nTupleESC[esc] = np.sign(nTupleESC[esc]) * 16
huffIndex = int(np.abs(nTupleESC) @ powers)
hexVal = LUT[huffIndex, 2]
huffLen = LUT[huffIndex, 1]
bits = format(int(hexVal), f'0{int(huffLen)}b')
escSeq = ''
for k in range(nTupleSize):
if esc[k]:
escSeq += '1' * N[k]
escSeq += '0'
escSeq += format(abs(nTuple[k]) - (1 << N4[k]), f'0{N4[k]}b')
signBits = ''.join('1' if v < 0 else '0' for v in nTuple)
huffSec.append(bits + signBits + escSeq)
return ''.join(huffSec)
# ------------------ DECODE ------------------
def decode_huff(huff_sec, huff_LUT):
"""
Decode a Huffman-encoded stream.
Parameters
----------
huff_sec : array-like of int or str
Huffman encoded stream as a sequence of 0 and 1 (string or list/array).
huff_LUT : dict
Huffman lookup table with keys:
- 'invTable': inverse table (numpy array)
- 'codebook': codebook number
- 'nTupleSize': tuple size
- 'maxAbsCodeVal': maximum absolute code value
- 'signedValues': True/False
Returns
-------
decCoeffs : list of int
Decoded quantized coefficients.
"""
h = huff_LUT['invTable']
huffCodebook = huff_LUT['codebook']
nTupleSize = huff_LUT['nTupleSize']
maxAbsCodeVal = huff_LUT['maxAbsCodeVal']
signedValues = huff_LUT['signedValues']
# Convert string to array of ints
if isinstance(huff_sec, str):
huff_sec = np.array([int(b) for b in huff_sec])
eos = False
decCoeffs = []
streamIndex = 0
while not eos:
wordbit = 0
r = 0 # start at root
# Decode Huffman word using inverse table
while True:
b = huff_sec[streamIndex + wordbit]
wordbit += 1
rOld = r
r = h[rOld, b]
if h[r, 0] == 0 and h[r, 1] == 0:
symbolIndex = h[r, 2] - 1 # zero-based
streamIndex += wordbit
break
# Decode n-tuple magnitudes
if signedValues:
base = 2 * maxAbsCodeVal + 1
nTupleDec = []
tmp = symbolIndex
for p in reversed(range(nTupleSize)):
val = tmp // (base ** p)
nTupleDec.append(val - maxAbsCodeVal)
tmp = tmp % (base ** p)
nTupleDec = np.array(nTupleDec)
else:
base = maxAbsCodeVal + 1
nTupleDec = []
tmp = symbolIndex
for p in reversed(range(nTupleSize)):
val = tmp // (base ** p)
nTupleDec.append(val)
tmp = tmp % (base ** p)
nTupleDec = np.array(nTupleDec)
# Apply sign bits
nTupleSignBits = huff_sec[streamIndex:streamIndex + nTupleSize]
nTupleSign = -(np.sign(nTupleSignBits - 0.5))
streamIndex += nTupleSize
nTupleDec = nTupleDec * nTupleSign
# Handle escape sequences
escIndex = np.where(np.abs(nTupleDec) == 16)[0]
if huffCodebook == 11 and escIndex.size > 0:
for idx in escIndex:
N = 0
b = huff_sec[streamIndex]
while b:
N += 1
b = huff_sec[streamIndex + N]
streamIndex += N
N4 = N + 4
escape_word = huff_sec[streamIndex:streamIndex + N4]
escape_value = 2 ** N4 + int("".join(map(str, escape_word)), 2)
nTupleDec[idx] = escape_value
streamIndex += N4 + 1
# Apply signs again
nTupleDec[escIndex] *= nTupleSign[escIndex]
decCoeffs.extend(nTupleDec.tolist())
if streamIndex >= len(huff_sec):
eos = True
return decCoeffs