blob: 82336e94507557a5cf3afdcfa397ba1f9aa74e6a [file]
from __future__ import annotations
import array
import math
import struct
import lc3
import pytest
@pytest.mark.parametrize("bit_depth,decoded_length", [(16, 960), (24, 1440)])
def test_decode_with_bit_depth(bit_depth, decoded_length) -> None:
decoder = lc3.Decoder(frame_duration_us=10000, sample_rate_hz=48000)
decoded_frame = decoder.decode(bytes(120), bit_depth=bit_depth)
assert isinstance(decoded_frame, bytes)
assert len(decoded_frame) == decoded_length
def test_decode_without_bit_depth() -> None:
decoder = lc3.Decoder(frame_duration_us=10000, sample_rate_hz=48000)
decoded_frame = decoder.decode(bytes(120))
assert isinstance(decoded_frame, array.array)
assert len(decoded_frame) == 480
assert all(isinstance(e, float) for e in decoded_frame)
def test_decode_with_bad_bit_depth() -> None:
decoder = lc3.Decoder(frame_duration_us=10000, sample_rate_hz=48000)
with pytest.raises(lc3.InvalidArgumentError):
decoder.decode(bytes(120), bit_depth=128)
@pytest.mark.parametrize("bit_depth", [16, 24])
def test_encode_with_bit_depth(bit_depth) -> None:
encoder = lc3.Encoder(frame_duration_us=10000, sample_rate_hz=48000)
encoded_frame = encoder.encode(bytes(1920), num_bytes=120, bit_depth=bit_depth)
assert isinstance(encoded_frame, bytes)
assert len(encoded_frame) == 120
@pytest.mark.parametrize("pcm", [bytes(1920), [0.0] * 1920])
def test_encode_without_bit_depth(pcm) -> None:
encoder = lc3.Encoder(frame_duration_us=10000, sample_rate_hz=48000)
encoded_frame = encoder.encode(pcm, num_bytes=120, bit_depth=None)
assert isinstance(encoded_frame, bytes)
assert len(encoded_frame) == 120
def test_encode_with_bad_bit_depth() -> None:
encoder = lc3.Encoder(frame_duration_us=10000, sample_rate_hz=48000)
with pytest.raises(lc3.InvalidArgumentError):
encoder.encode(bytes(1920), num_bytes=120, bit_depth=128)
def _generate_sine(
num_samples: int, sample_rate_hz: int, freq_hz: float = 440.0, amp: float = 0.5
) -> list[float]:
"""Generates a sinusoidal test signal with floating-point amplitudes."""
return [
amp * math.sin(2.0 * math.pi * freq_hz * i / sample_rate_hz)
for i in range(num_samples)
]
def _floats_to_pcm(floats: list[float], bit_depth: int | None) -> bytes | list[float]:
"""Converts a float sample sequence to the appropriate PCM representation."""
if bit_depth is None:
return list(floats)
if bit_depth == 16:
raw = bytearray()
for x in floats:
val = max(-32768, min(32767, int(x * 32767)))
raw.extend(struct.pack("<h", val))
return bytes(raw)
if bit_depth == 24:
raw = bytearray()
for x in floats:
val = max(-8388608, min(8388607, int(x * 8388607)))
raw.extend(val.to_bytes(3, byteorder="little", signed=True))
return bytes(raw)
raise ValueError(f"Unsupported bit_depth: {bit_depth}")
def _pcm_to_floats(
pcm: bytes | array.array[float] | list[float], bit_depth: int | None
) -> list[float]:
"""Converts decoded PCM output back to normalized float samples."""
if bit_depth is None:
return list(pcm)
if bit_depth == 16:
ints = struct.unpack(f"<{len(pcm) // 2}h", pcm)
return [x / 32768.0 for x in ints]
if bit_depth == 24:
res = []
for i in range(0, len(pcm), 3):
val = int.from_bytes(pcm[i : i + 3], byteorder="little", signed=True)
res.append(val / 8388608.0)
return res
raise ValueError(f"Unsupported bit_depth: {bit_depth}")
def _deinterleave(interleaved: list[float], num_channels: int) -> list[list[float]]:
"""Separates an interleaved multi-channel sample stream into per-channel lists."""
return [interleaved[ch::num_channels] for ch in range(num_channels)]
def _pearson_correlation(x: list[float], y: list[float]) -> float:
"""Computes the Pearson correlation coefficient between two signals."""
n = min(len(x), len(y))
if n == 0:
return 0.0
x_s = x[:n]
y_s = y[:n]
mx = sum(x_s) / n
my = sum(y_s) / n
num = sum((xi - mx) * (yi - my) for xi, yi in zip(x_s, y_s))
den_x = sum((xi - mx) ** 2 for xi in x_s)
den_y = sum((yi - my) ** 2 for yi in y_s)
den = math.sqrt(den_x * den_y)
return num / den if den > 0 else 0.0
def _calculate_rms(samples: list[float]) -> float:
"""Calculates the root mean square (RMS) amplitude of samples."""
if not samples:
return 0.0
return math.sqrt(sum(x * x for x in samples) / len(samples))
@pytest.mark.parametrize("bit_depth", [None, 16, 24])
def test_single_frame_roundtrip(bit_depth: int | None) -> None:
"""Validates single-frame encoding and decoding roundtrip."""
encoder = lc3.Encoder(frame_duration_us=10000, sample_rate_hz=48000)
decoder = lc3.Decoder(frame_duration_us=10000, sample_rate_hz=48000)
samples_per_frame = encoder.get_frame_samples()
num_bytes = encoder.get_frame_bytes(64000)
sine_frame = _generate_sine(samples_per_frame, 48000, freq_hz=440.0)
pcm_in = _floats_to_pcm(sine_frame, bit_depth)
encoded = encoder.encode(pcm_in, num_bytes=num_bytes, bit_depth=bit_depth)
assert isinstance(encoded, bytes)
assert len(encoded) == num_bytes
decoded = decoder.decode(encoded, bit_depth=bit_depth)
if bit_depth is None:
assert isinstance(decoded, array.array)
assert len(decoded) == samples_per_frame
else:
assert isinstance(decoded, bytes)
bytes_per_sample = 2 if bit_depth == 16 else 3
assert len(decoded) == samples_per_frame * bytes_per_sample
decoded_floats = _pcm_to_floats(decoded, bit_depth)
assert any(abs(x) > 0.0 for x in decoded_floats)
assert not any(math.isnan(x) or math.isinf(x) for x in decoded_floats)
@pytest.mark.parametrize("frame_duration_us", [10000, 7500])
@pytest.mark.parametrize("sample_rate_hz", [48000, 16000])
@pytest.mark.parametrize("num_channels", [1, 2])
@pytest.mark.parametrize("bit_depth", [None, 16, 24])
def test_stream_roundtrip(
frame_duration_us: int,
sample_rate_hz: int,
num_channels: int,
bit_depth: int | None,
) -> None:
"""Validates multi-frame audio streaming roundtrip with delay compensation."""
encoder = lc3.Encoder(
frame_duration_us=frame_duration_us,
sample_rate_hz=sample_rate_hz,
num_channels=num_channels,
)
decoder = lc3.Decoder(
frame_duration_us=frame_duration_us,
sample_rate_hz=sample_rate_hz,
num_channels=num_channels,
)
spf = encoder.get_frame_samples()
delay = encoder.get_delay_samples()
bitrate = 64000 * num_channels
num_bytes = encoder.get_frame_bytes(bitrate)
num_frames = 10
# Generate test tones: 440 Hz for ch0, 880 Hz for ch1 (if stereo)
channel_inputs = [
_generate_sine(num_frames * spf, sample_rate_hz, freq_hz=440.0 * (c + 1))
for c in range(num_channels)
]
decoded_stream: list[float] = []
for f in range(num_frames):
frame_floats: list[float] = []
for s in range(spf):
for c in range(num_channels):
frame_floats.append(channel_inputs[c][f * spf + s])
pcm_in = _floats_to_pcm(frame_floats, bit_depth)
encoded = encoder.encode(pcm_in, num_bytes=num_bytes, bit_depth=bit_depth)
decoded = decoder.decode(encoded, bit_depth=bit_depth)
decoded_stream.extend(_pcm_to_floats(decoded, bit_depth))
decoded_channels = _deinterleave(decoded_stream, num_channels)
for c in range(num_channels):
in_ch = channel_inputs[c]
out_ch = decoded_channels[c]
# Delay compensation: align input with output
comp_in = in_ch[: len(out_ch) - delay]
comp_out = out_ch[delay : delay + len(comp_in)]
# Discard the first frame to allow encoder/decoder filter warm-up
eval_in = comp_in[spf:]
eval_out = comp_out[spf:]
corr = _pearson_correlation(eval_in, eval_out)
rms = _calculate_rms(eval_out)
assert corr > 0.95, f"Correlation {corr:.4f} below threshold for channel {c}"
assert 0.20 <= rms <= 0.60, f"RMS {rms:.4f} outside expected range"
assert not any(math.isnan(x) or math.isinf(x) for x in eval_out)
def test_stereo_channel_separation() -> None:
"""Verifies that stereo encoding and decoding maintains channel separation."""
sample_rate_hz = 48000
frame_duration_us = 10000
encoder = lc3.Encoder(
frame_duration_us=frame_duration_us,
sample_rate_hz=sample_rate_hz,
num_channels=2,
)
decoder = lc3.Decoder(
frame_duration_us=frame_duration_us,
sample_rate_hz=sample_rate_hz,
num_channels=2,
)
spf = encoder.get_frame_samples()
delay = encoder.get_delay_samples()
num_bytes = encoder.get_frame_bytes(128000)
num_frames = 10
# Ch0: 440 Hz, Ch1: 880 Hz
ch0 = _generate_sine(num_frames * spf, sample_rate_hz, freq_hz=440.0)
ch1 = _generate_sine(num_frames * spf, sample_rate_hz, freq_hz=880.0)
decoded_stream: list[float] = []
for f in range(num_frames):
frame_floats: list[float] = []
for s in range(spf):
frame_floats.append(ch0[f * spf + s])
frame_floats.append(ch1[f * spf + s])
pcm_in = _floats_to_pcm(frame_floats, 16)
encoded = encoder.encode(pcm_in, num_bytes=num_bytes, bit_depth=16)
decoded = decoder.decode(encoded, bit_depth=16)
decoded_stream.extend(_pcm_to_floats(decoded, 16))
decoded_channels = _deinterleave(decoded_stream, 2)
comp_ch0_in = ch0[spf : len(decoded_channels[0]) - delay]
comp_ch0_out = decoded_channels[0][spf + delay : spf + delay + len(comp_ch0_in)]
comp_ch1_in = ch1[spf : len(decoded_channels[1]) - delay]
comp_ch1_out = decoded_channels[1][spf + delay : spf + delay + len(comp_ch1_in)]
corr_ch0_match = _pearson_correlation(comp_ch0_in, comp_ch0_out)
corr_ch0_cross = _pearson_correlation(comp_ch0_in, comp_ch1_out)
corr_ch1_match = _pearson_correlation(comp_ch1_in, comp_ch1_out)
corr_ch1_cross = _pearson_correlation(comp_ch1_in, comp_ch0_out)
assert corr_ch0_match > 0.95
assert abs(corr_ch0_cross) < 0.15
assert corr_ch1_match > 0.95
assert abs(corr_ch1_cross) < 0.15
@pytest.mark.parametrize("bit_depth", [None, 16, 24])
def test_silence_roundtrip(bit_depth: int | None) -> None:
"""Verifies that encoding silence produces silent output without noise."""
encoder = lc3.Encoder(frame_duration_us=10000, sample_rate_hz=48000)
decoder = lc3.Decoder(frame_duration_us=10000, sample_rate_hz=48000)
spf = encoder.get_frame_samples()
num_bytes = encoder.get_frame_bytes(64000)
silent_frame = [0.0] * spf
pcm_in = _floats_to_pcm(silent_frame, bit_depth)
for _ in range(5):
encoded = encoder.encode(pcm_in, num_bytes=num_bytes, bit_depth=bit_depth)
decoded = decoder.decode(encoded, bit_depth=bit_depth)
decoded_floats = _pcm_to_floats(decoded, bit_depth)
rms = _calculate_rms(decoded_floats)
assert rms < 1e-4, f"Expected silence, but got RMS {rms:.6f}"
@pytest.mark.parametrize("bit_depth", [None, 16, 24])
def test_packet_loss_concealment(bit_depth: int | None) -> None:
"""Verifies PLC generates smooth concealed frames when packet loss occurs."""
encoder = lc3.Encoder(frame_duration_us=10000, sample_rate_hz=48000)
decoder = lc3.Decoder(frame_duration_us=10000, sample_rate_hz=48000)
spf = encoder.get_frame_samples()
num_bytes = encoder.get_frame_bytes(64000)
audio = _generate_sine(3 * spf, 48000, freq_hz=440.0)
# Feed 3 good frames
for f in range(3):
pcm_in = _floats_to_pcm(audio[f * spf : (f + 1) * spf], bit_depth)
encoded = encoder.encode(pcm_in, num_bytes=num_bytes, bit_depth=bit_depth)
decoder.decode(encoded, bit_depth=bit_depth)
# Feed 2 lost frames (PLC)
plc1 = decoder.decode(None, bit_depth=bit_depth)
plc2 = decoder.decode(None, bit_depth=bit_depth)
plc1_floats = _pcm_to_floats(plc1, bit_depth)
plc2_floats = _pcm_to_floats(plc2, bit_depth)
assert len(plc1_floats) == spf
assert len(plc2_floats) == spf
rms1 = _calculate_rms(plc1_floats)
rms2 = _calculate_rms(plc2_floats)
assert rms1 > 0.05, f"PLC frame 1 should have energy, got {rms1:.4f}"
assert rms2 <= rms1 + 1e-3, (
f"PLC frame 2 should decay or stay bounded, got {rms2:.4f} vs {rms1:.4f}"
)