From 1b8cc641f192a95f91f0ea20095da247d3118621 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:45:28 +0000 Subject: [PATCH 1/3] Initial plan From e8db0e00d0d4d44d3aae849f885335a5295b377f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:21:04 +0000 Subject: [PATCH 2/3] Fix G.722 ADPCM state reset between TTS chunks via G722Encoder Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/codecs.md | 6 ++ tests/codecs/test_base.py | 26 ++++++ tests/codecs/test_g722.py | 192 ++++++++++++++++++++++++++++++++++++++ tests/test_audio.py | 43 +++++++++ voip/audio.py | 9 +- voip/codecs/base.py | 73 ++++++++++++++- voip/codecs/g722.py | 81 +++++++++++++++- 7 files changed, 426 insertions(+), 4 deletions(-) diff --git a/docs/codecs.md b/docs/codecs.md index 34b5c64..9555a39 100644 --- a/docs/codecs.md +++ b/docs/codecs.md @@ -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 @@ -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/ diff --git a/tests/codecs/test_base.py b/tests/codecs/test_base.py index 57791f4..b87fe31 100644 --- a/tests/codecs/test_base.py +++ b/tests/codecs/test_base.py @@ -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 diff --git a/tests/codecs/test_g722.py b/tests/codecs/test_g722.py index 8a3e4dc..7dde5a6 100644 --- a/tests/codecs/test_g722.py +++ b/tests/codecs/test_g722.py @@ -276,3 +276,195 @@ 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" + ) + """Per-packet stateless decoding diverges from reference for packets 1+. + + This test documents the original bug: resetting ADPCM state each + packet causes the decoded signal to be near-silent for all but the + first packet, making the echo 'too short' and 'robotic'. + """ + import io # noqa: PLC0415 + + import av # noqa: PLC0415 + + packets = self._make_encoded_packets(3) + + # Reference: decode all bytes together. + resampler = av.audio.resampler.AudioResampler( + format="fltp", layout="mono", rate=16000 + ) + ref_frames: list[np.ndarray] = [] + with av.open( + io.BytesIO(b"".join(packets)), + mode="r", + format="g722", + options={"sample_rate": "8000"}, + ) as container: + for f in container.decode(audio=0): + for rs in resampler.resample(f): + ref_frames.append(rs.to_ndarray().flatten()) + reference = np.concatenate(ref_frames) + + # Stateless (original buggy behaviour): fresh context per packet. + stateless_parts = [G722.decode(p, 16000) for p in packets] + + # Packet 0 is identical (both start from zero state). + assert np.allclose(stateless_parts[0], reference[: G722.frame_size], atol=1e-5) + # Packets 1+ diverge: stateless is near-silent, reference has full signal. + for i, part in enumerate(stateless_parts[1:], start=1): + ref_segment = reference[i * G722.frame_size : (i + 1) * G722.frame_size] + mse = float(np.mean((part - ref_segment) ** 2)) + assert mse > 0.01, ( # near-silence vs full-amplitude signal + f"Packet {i}: expected stateless decoder to diverge from reference " + f"(ADPCM state reset), but MSE={mse:.6f} is too low" + ) diff --git a/tests/test_audio.py b/tests/test_audio.py index a3f5967..0c546fd 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -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.""" diff --git a/voip/audio.py b/voip/audio.py index c8a9aa6..1275dda 100644 --- a/voip/audio.py +++ b/voip/audio.py @@ -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 @@ -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. @@ -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( { @@ -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) diff --git a/voip/codecs/base.py b/voip/codecs/base.py index 10ee694..98c16cd 100644 --- a/voip/codecs/base.py +++ b/voip/codecs/base.py @@ -24,7 +24,7 @@ from voip.sdp.types import RTPPayloadFormat -__all__ = ["PayloadDecoder", "PerPacketDecoder", "RTPCodec"] +__all__ = ["PayloadDecoder", "PayloadEncoder", "PerPacketDecoder", "PerPacketEncoder", "RTPCodec"] class PayloadDecoder(Protocol): @@ -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. @@ -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], @@ -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. @@ -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) diff --git a/voip/codecs/g722.py b/voip/codecs/g722.py index deab23e..a819e2d 100644 --- a/voip/codecs/g722.py +++ b/voip/codecs/g722.py @@ -8,6 +8,11 @@ stateful decoding that preserves the ADPCM predictor state across consecutive RTP packets. +Use [`G722Encoder`][voip.codecs.g722.G722Encoder] (via +[`G722.create_encoder`][voip.codecs.g722.G722.create_encoder]) for per-call +stateful encoding that preserves the ADPCM predictor state across consecutive +TTS chunks so that the encoded stream sounds natural and unbroken. + Requires the ``hd-audio`` extra: ``pip install voip[hd-audio]``. """ @@ -24,7 +29,7 @@ from voip.codecs.av import PyAVCodec -__all__ = ["G722", "G722Decoder"] +__all__ = ["G722", "G722Decoder", "G722Encoder"] class G722(PyAVCodec): @@ -79,6 +84,24 @@ def packetize(cls, audio: np.ndarray) -> Iterator[bytes]: for i in range(0, len(encoded), payload_size): yield encoded[i : i + payload_size] + @classmethod + def create_encoder(cls) -> G722Encoder: + """Create a stateful per-call G.722 encoder. + + Returns a [`G722Encoder`][voip.codecs.g722.G722Encoder] that preserves + the ADPCM predictor state across consecutive + [`packetize`][voip.codecs.g722.G722Encoder.packetize] calls. Pass the + returned encoder to + [`AudioCall`][voip.audio.AudioCall] so that TTS audio chunks that span + multiple [`send_rtp_audio`][voip.audio.AudioCall.send_rtp_audio] calls + are encoded with a continuous ADPCM predictor rather than resetting at + each chunk boundary. + + Returns: + A new [`G722Encoder`][voip.codecs.g722.G722Encoder] instance. + """ + return G722Encoder() + @classmethod def create_decoder( cls, output_rate_hz: int, *, input_rate_hz: int | None = None @@ -159,3 +182,59 @@ def decode(self, payload: bytes) -> np.ndarray: for resampled in self.resampler.resample(frame) ] return np.concatenate(frames) if frames else np.array([], dtype=np.float32) + + +@dataclasses.dataclass(slots=True) +class G722Encoder: + """Stateful G.722 encoder that preserves ADPCM predictor state across encode calls. + + Creates a single persistent + [`av.CodecContext`](https://pyav.basswood-io.com/docs/stable/api/codec.html#av.codec.context.CodecContext) + for the life of the encoder and feeds each TTS audio chunk to the same + context. This eliminates the per-chunk predictor reset that causes robotic + artefacts when encoding a G.722 stream from multiple consecutive TTS chunks. + + Use [`G722.create_encoder`][voip.codecs.g722.G722.create_encoder] rather + than instantiating this class directly. + + Attributes: + codec_context: Persistent G.722 encoder context shared across all + [`packetize`][voip.codecs.g722.G722Encoder.packetize] calls on + this instance. + sample_count: Running count of PCM samples encoded so far, used as the + presentation timestamp for each new audio frame. + """ + + codec_context: av.AudioCodecContext = dataclasses.field(init=False, repr=False) + sample_count: int = dataclasses.field(init=False, repr=False, default=0) + + def __post_init__(self) -> None: + self.codec_context = typing.cast( + av.AudioCodecContext, av.CodecContext.create("g722", "w") + ) + self.codec_context.sample_rate = G722.sample_rate_hz + self.codec_context.format = av.AudioFormat("s16") + self.codec_context.layout = av.AudioLayout("mono") + self.codec_context.open() + + def packetize(self, audio: np.ndarray) -> Iterator[bytes]: + """Encode *audio*, preserving ADPCM state from prior chunks. + + Feeds *audio* into the persistent codec context without flushing, so + the ADPCM predictor state carries over to the next call. Yields one + 160-byte RTP payload per 20 ms G.722 frame. + + Args: + audio: Float32 mono PCM at `G722.sample_rate_hz` Hz. + + Yields: + Encoded G.722 payload bytes, 160 bytes per 20 ms RTP packet. + """ + pcm = np.clip(np.round(audio * 32768.0), -32768, 32767).astype(np.int16) + frame = av.AudioFrame.from_ndarray(pcm[np.newaxis, :], format="s16", layout="mono") + frame.sample_rate = G722.sample_rate_hz + frame.pts = self.sample_count + self.sample_count += len(audio) + encoded = b"".join(bytes(packet) for packet in self.codec_context.encode(frame)) + payload_size = G722.frame_size // 2 + return (encoded[i : i + payload_size] for i in range(0, len(encoded), payload_size)) From 87410c3d7040d7832644dc7df6094539a52ebb48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:38:57 +0000 Subject: [PATCH 3/3] Fix duplicate test body in test_stateless_encode__diverges_after_first_chunk Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/codecs/test_g722.py | 41 --------------------------------------- 1 file changed, 41 deletions(-) diff --git a/tests/codecs/test_g722.py b/tests/codecs/test_g722.py index 7dde5a6..4048e56 100644 --- a/tests/codecs/test_g722.py +++ b/tests/codecs/test_g722.py @@ -427,44 +427,3 @@ def decode_stream(raw: bytes) -> np.ndarray: 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" ) - """Per-packet stateless decoding diverges from reference for packets 1+. - - This test documents the original bug: resetting ADPCM state each - packet causes the decoded signal to be near-silent for all but the - first packet, making the echo 'too short' and 'robotic'. - """ - import io # noqa: PLC0415 - - import av # noqa: PLC0415 - - packets = self._make_encoded_packets(3) - - # Reference: decode all bytes together. - resampler = av.audio.resampler.AudioResampler( - format="fltp", layout="mono", rate=16000 - ) - ref_frames: list[np.ndarray] = [] - with av.open( - io.BytesIO(b"".join(packets)), - mode="r", - format="g722", - options={"sample_rate": "8000"}, - ) as container: - for f in container.decode(audio=0): - for rs in resampler.resample(f): - ref_frames.append(rs.to_ndarray().flatten()) - reference = np.concatenate(ref_frames) - - # Stateless (original buggy behaviour): fresh context per packet. - stateless_parts = [G722.decode(p, 16000) for p in packets] - - # Packet 0 is identical (both start from zero state). - assert np.allclose(stateless_parts[0], reference[: G722.frame_size], atol=1e-5) - # Packets 1+ diverge: stateless is near-silent, reference has full signal. - for i, part in enumerate(stateless_parts[1:], start=1): - ref_segment = reference[i * G722.frame_size : (i + 1) * G722.frame_size] - mse = float(np.mean((part - ref_segment) ** 2)) - assert mse > 0.01, ( # near-silence vs full-amplitude signal - f"Packet {i}: expected stateless decoder to diverge from reference " - f"(ADPCM state reset), but MSE={mse:.6f} is too low" - )