Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/codecs.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ These codecs require the `hd-audio` extra (`pip install voip[hd-audio]`).

::: voip.codecs.g722.G722Decoder

::: voip.codecs.g722.G722Encoder

::: voip.codecs.opus.Opus

## Registry
Expand All @@ -49,8 +51,12 @@ These codecs require the `hd-audio` extra (`pip install voip[hd-audio]`).

::: voip.codecs.base.PayloadDecoder

::: voip.codecs.base.PayloadEncoder

::: voip.codecs.base.PerPacketDecoder

::: voip.codecs.base.PerPacketEncoder

::: voip.codecs.av.PyAVCodec

[ffmpeg]: https://ffmpeg.org/
Expand Down
26 changes: 26 additions & 0 deletions tests/codecs/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,29 @@ def test_per_packet_decoder__delegates_to_codec_decode(self):
result = decoder.decode(b"payload")
mock_decode.assert_called_once_with(b"payload", 16000, input_rate_hz=8000)
assert result.dtype == np.float32


class TestCreateEncoder:
def test_create_encoder__returns_per_packet_encoder(self):
"""RTPCodec.create_encoder returns a PerPacketEncoder for stateless codecs."""
from voip.codecs.base import PerPacketEncoder # noqa: PLC0415

encoder = PCMA.create_encoder()
assert isinstance(encoder, PerPacketEncoder)

def test_create_encoder__stores_codec(self):
"""PerPacketEncoder holds the codec class."""
from voip.codecs.base import PerPacketEncoder # noqa: PLC0415

encoder = PCMA.create_encoder()
assert isinstance(encoder, PerPacketEncoder)
assert encoder.codec is PCMA

def test_per_packet_encoder__delegates_to_codec_packetize(self):
"""PerPacketEncoder.packetize calls codec.packetize with the audio array."""
fake_packets = [b"\xd5" * 160]
with patch.object(PCMA, "packetize", return_value=iter(fake_packets)) as mock_pac:
encoder = PCMA.create_encoder()
result = list(encoder.packetize(np.zeros(160, dtype=np.float32)))
mock_pac.assert_called_once()
assert result == fake_packets
151 changes: 151 additions & 0 deletions tests/codecs/test_g722.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,154 @@ def test_stateless_decode__diverges_after_first_packet(self):
f"Packet {i}: expected stateless decoder to diverge from reference "
f"(ADPCM state reset), but MSE={mse:.6f} is too low"
)


class TestG722CreateEncoder:
def test_create_encoder__returns_g722_encoder(self):
"""create_encoder returns a G722Encoder instance."""
from voip.codecs.g722 import G722Encoder # noqa: PLC0415

encoder = G722.create_encoder()
assert isinstance(encoder, G722Encoder)

def test_create_encoder__codec_context_is_open(self):
"""create_encoder initialises a writable G.722 codec context."""
import av # noqa: PLC0415

encoder = G722.create_encoder()
assert isinstance(encoder.codec_context, av.CodecContext)

def test_create_encoder__sample_count_starts_at_zero(self):
"""create_encoder initialises sample_count to zero."""
encoder = G722.create_encoder()
assert encoder.sample_count == 0


class TestG722Encoder:
def test_encoder__packetize_returns_bytes(self):
"""G722Encoder.packetize yields bytes for silent PCM input."""
encoder = G722.create_encoder()
packets = list(encoder.packetize(np.zeros(320, dtype=np.float32)))
assert len(packets) == 1
assert isinstance(packets[0], bytes)
assert len(packets[0]) == G722.frame_size // 2 # 160 bytes

def test_encoder__packetize_increments_sample_count(self):
"""packetize advances sample_count by the number of input samples."""
encoder = G722.create_encoder()
list(encoder.packetize(np.zeros(320, dtype=np.float32)))
list(encoder.packetize(np.zeros(320, dtype=np.float32)))
assert encoder.sample_count == 640

def test_encoder__packetize_yields_160_byte_chunks(self):
"""packetize yields 160-byte payloads (G.722 2:1 sample-to-byte ratio)."""
encoder = G722.create_encoder()
packets = list(encoder.packetize(np.zeros(640, dtype=np.float32)))
assert len(packets) == 2
assert all(len(p) == 160 for p in packets)

def test_encoder__preserves_adpcm_state_across_chunks(self):
"""G722Encoder preserves ADPCM predictor state across consecutive packetize calls.

Encoding a continuous sine wave in two separate chunks with the stateful
G722Encoder must produce the same output as encoding the whole signal at
once. If ADPCM state resets between chunks the decoded output diverges.
"""
import io # noqa: PLC0415

import av # noqa: PLC0415

chunk_frames = 3 # 3 × 20 ms
total_samples = chunk_frames * G722.frame_size * 2 # split into 2 equal halves
t = np.linspace(0, total_samples / G722.sample_rate_hz, total_samples, endpoint=False)
signal = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32)

# Reference: encode the entire signal with a fresh context (ground truth).
reference_encoded = b"".join(
bytes(p) for p in G722.create_encoder().packetize(signal)
)

# Stateful encoder: encode in two halves.
stateful_encoder = G722.create_encoder()
half = total_samples // 2
encoded_chunks = b"".join(
bytes(p)
for chunk in (signal[:half], signal[half:])
for p in stateful_encoder.packetize(chunk)
)

# Both streams must decode to the same audio.
def decode_stream(raw: bytes) -> np.ndarray:
resampler = av.audio.resampler.AudioResampler(
format="fltp", layout="mono", rate=G722.sample_rate_hz
)
frames: list[np.ndarray] = []
with av.open(
io.BytesIO(raw), mode="r", format="g722",
options={"sample_rate": str(G722.rtp_clock_rate_hz)},
) as container:
for f in container.decode(audio=0):
for rs in resampler.resample(f):
frames.append(rs.to_ndarray().flatten())
return np.concatenate(frames) if frames else np.array([], dtype=np.float32)

reference_audio = decode_stream(reference_encoded)
stateful_audio = decode_stream(encoded_chunks)

min_len = min(len(reference_audio), len(stateful_audio))
assert min_len > 0
assert np.allclose(reference_audio[:min_len], stateful_audio[:min_len], atol=1e-5), (
"G722Encoder stateful output differs from reference: "
"ADPCM state may not be preserved across chunk boundaries"
)

def test_stateless_encode__diverges_after_first_chunk(self):
"""Stateless encoding resets ADPCM state between chunks, producing divergence.

This test documents the bug: creating a fresh codec context for each TTS
chunk causes the receiver to hear robotic, broken audio after the first chunk.
"""
import io # noqa: PLC0415

import av # noqa: PLC0415

chunk_frames = 3
total_samples = chunk_frames * G722.frame_size * 2
t = np.linspace(0, total_samples / G722.sample_rate_hz, total_samples, endpoint=False)
signal = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32)

# Reference: encode all at once (correct, state-preserving).
reference_encoded = b"".join(
bytes(p) for p in G722.create_encoder().packetize(signal)
)

# Stateless: separate encode_pcm call per chunk (original bug).
half = total_samples // 2
stateless_encoded = G722.encode(signal[:half]) + G722.encode(signal[half:])

def decode_stream(raw: bytes) -> np.ndarray:
resampler = av.audio.resampler.AudioResampler(
format="fltp", layout="mono", rate=G722.sample_rate_hz
)
frames: list[np.ndarray] = []
with av.open(
io.BytesIO(raw), mode="r", format="g722",
options={"sample_rate": str(G722.rtp_clock_rate_hz)},
) as container:
for f in container.decode(audio=0):
for rs in resampler.resample(f):
frames.append(rs.to_ndarray().flatten())
return np.concatenate(frames) if frames else np.array([], dtype=np.float32)

reference_audio = decode_stream(reference_encoded)
stateless_audio = decode_stream(stateless_encoded)

min_len = min(len(reference_audio), len(stateless_audio))
second_half_start = min_len // 2
ref_second = reference_audio[second_half_start:]
stat_second = stateless_audio[second_half_start:]
mse = float(np.mean((ref_second - stat_second[:len(ref_second)]) ** 2))
assert mse > 0.01, (
f"Expected stateless encoder to diverge from reference in second half "
f"(ADPCM state reset at chunk boundary), but MSE={mse:.6f} is too low"
)
43 changes: 43 additions & 0 deletions tests/test_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,49 @@ def test_decode_payload__pcma_uses_per_packet_decoder(self):
assert isinstance(call.payload_decoder, PerPacketDecoder)


class TestPayloadEncoder:
"""Tests that AudioCall initialises the payload_encoder correctly."""

def test_g722_media__uses_stateful_encoder(self):
"""G.722 AudioCall uses a G722Encoder that preserves ADPCM state."""
from voip.codecs.g722 import G722Encoder # noqa: PLC0415

call = make_audio_call(media=G722_MEDIA)
assert isinstance(call.payload_encoder, G722Encoder)

def test_pcma_media__uses_per_packet_encoder(self):
"""PCMA AudioCall uses a PerPacketEncoder (stateless)."""
from voip.codecs.base import PerPacketEncoder # noqa: PLC0415

call = make_audio_call(media=PCMA_MEDIA)
assert isinstance(call.payload_encoder, PerPacketEncoder)

def test_pcmu_media__uses_per_packet_encoder(self):
"""PCMU AudioCall uses a PerPacketEncoder (stateless)."""
from voip.codecs.base import PerPacketEncoder # noqa: PLC0415

call = make_audio_call(media=PCMU_MEDIA)
assert isinstance(call.payload_encoder, PerPacketEncoder)

async def test_send_rtp_audio__uses_payload_encoder(self):
"""send_rtp_audio routes encoding through payload_encoder.packetize."""
from unittest.mock import MagicMock # noqa: PLC0415

call = make_audio_call(media=PCMA_MEDIA)
remote_addr = ("10.0.0.1", 5004)
call.rtp.calls = {remote_addr: call}

fake_payload = b"\xd5" * 160
mock_encoder = MagicMock()
mock_encoder.packetize.return_value = iter([fake_payload])
call.payload_encoder = mock_encoder

with patch.object(call, "send_packet"):
await call.send_rtp_audio(np.zeros(160, dtype=np.float32))

mock_encoder.packetize.assert_called_once()


class TestAudioCallInit:
def test_init__raises_value_error_for_none_encoding_name(self):
"""Raise ValueError when the negotiated format has no encoding name."""
Expand Down
9 changes: 7 additions & 2 deletions voip/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import voip.codecs as codecs
from voip.codecs import RTPCodec
from voip.codecs.base import PayloadDecoder
from voip.codecs.base import PayloadDecoder, PayloadEncoder
from voip.rtp import RTPCall, RTPPacket
from voip.sdp.types import MediaDescription

Expand Down Expand Up @@ -73,6 +73,10 @@ class is stored on `codec` after `__post_init__` and used for all
#: Stateful for ADPCM codecs (e.g. G.722), stateless for others.
payload_decoder: PayloadDecoder = dataclasses.field(init=False, repr=False)

#: Per-call payload encoder, set in `__post_init__`.
#: Stateful for ADPCM codecs (e.g. G.722), stateless for others.
payload_encoder: PayloadEncoder = dataclasses.field(init=False, repr=False)

#: Outbound RTP sequence counter.
rtp_sequence_number: int = dataclasses.field(init=False, repr=False, default=0)
#: Outbound RTP timestamp counter.
Expand All @@ -90,6 +94,7 @@ def __post_init__(self) -> None:
self.payload_decoder = self.codec.create_decoder(
self.RESAMPLING_RATE_HZ, input_rate_hz=self.sample_rate
)
self.payload_encoder = self.codec.create_encoder()
logger.info(
json.dumps(
{
Expand Down Expand Up @@ -243,7 +248,7 @@ async def send_rtp_audio(self, audio: np.ndarray) -> None:
if remote_addr is None:
logger.warning("No remote RTP address for this call; dropping audio")
return
for payload in self.codec.packetize(audio):
for payload in self.payload_encoder.packetize(audio):
self.send_packet(self.next_rtp_packet(payload), remote_addr)
await asyncio.sleep(self.RTP_PACKET_DURATION_SECS)

Expand Down
73 changes: 72 additions & 1 deletion voip/codecs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from voip.sdp.types import RTPPayloadFormat

__all__ = ["PayloadDecoder", "PerPacketDecoder", "RTPCodec"]
__all__ = ["PayloadDecoder", "PayloadEncoder", "PerPacketDecoder", "PerPacketEncoder", "RTPCodec"]


class PayloadDecoder(Protocol):
Expand All @@ -49,6 +49,30 @@ def decode(self, payload: bytes) -> np.ndarray:
...


class PayloadEncoder(Protocol):
"""Protocol for per-call RTP payload encoders.

Implementations encode float32 mono PCM audio and yield one raw RTP
payload per 20 ms frame. Stateful implementations (e.g.
[`G722Encoder`][voip.codecs.g722.G722Encoder]) preserve the codec
predictor state across successive
[`packetize`][voip.codecs.base.PayloadEncoder.packetize] calls within a
single call session so that ADPCM continuity is maintained across TTS
chunks.
"""

def packetize(self, audio: np.ndarray) -> Iterator[bytes]:
"""Encode *audio* and yield one RTP payload per 20 ms frame.

Args:
audio: Float32 mono PCM at the codec's `sample_rate_hz` Hz.

Yields:
Encoded payload bytes, one per RTP packet.
"""
...


class RTPCodec:
"""Base class for RTP audio codecs.

Expand All @@ -61,6 +85,9 @@ class RTPCodec:
Per-call decoder state (required for ADPCM codecs such as G.722) is
managed by [`PayloadDecoder`][voip.codecs.base.PayloadDecoder] instances
returned by [`create_decoder`][voip.codecs.base.RTPCodec.create_decoder].
Per-call encoder state (required for ADPCM codecs such as G.722) is
managed by [`PayloadEncoder`][voip.codecs.base.PayloadEncoder] instances
returned by [`create_encoder`][voip.codecs.base.RTPCodec.create_encoder].

Concrete subclasses define codec-specific class variables and override
[`decode`][voip.codecs.base.RTPCodec.decode],
Expand Down Expand Up @@ -194,6 +221,23 @@ def create_decoder(
"""
return PerPacketDecoder(cls, output_rate_hz, input_rate_hz)

@classmethod
def create_encoder(cls) -> PayloadEncoder:
"""Create a stateless per-call payload encoder for this codec.

Override in subclasses that require stateful encoding across RTP
packets (e.g. G.722 ADPCM — see
[`G722.create_encoder`][voip.codecs.g722.G722.create_encoder]).

Returns:
A [`PayloadEncoder`][voip.codecs.base.PayloadEncoder] that, by
default, is a
[`PerPacketEncoder`][voip.codecs.base.PerPacketEncoder]
delegating each call to
[`packetize`][voip.codecs.base.RTPCodec.packetize].
"""
return PerPacketEncoder(cls)

@classmethod
def encode(cls, samples: np.ndarray) -> bytes:
"""Encode float32 mono PCM to an RTP payload.
Expand Down Expand Up @@ -257,3 +301,30 @@ def decode(self, payload: bytes) -> np.ndarray:
return self.codec.decode(
payload, self.output_rate_hz, input_rate_hz=self.input_rate_hz
)


@dataclasses.dataclass(frozen=True)
class PerPacketEncoder:
"""Stateless payload encoder that delegates to the codec's `packetize` classmethod.

Each [`packetize`][voip.codecs.base.PerPacketEncoder.packetize] call
forwards directly to
[`RTPCodec.packetize`][voip.codecs.base.RTPCodec.packetize]. Suitable for
stateless codecs such as PCMA, PCMU, and Opus.

Attributes:
codec: Codec class to delegate encoding to.
"""

codec: type[RTPCodec]

def packetize(self, audio: np.ndarray) -> Iterator[bytes]:
"""Encode *audio* and yield one RTP payload per 20 ms frame.

Args:
audio: Float32 mono PCM at the codec's `sample_rate_hz` Hz.

Yields:
Encoded payload bytes, one per RTP packet.
"""
return self.codec.packetize(audio)
Loading
Loading