From 2167ae7f10ace4ce7eea371c6ef209b4fa0efb05 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 27 May 2026 15:29:52 +0200 Subject: [PATCH] Add server-initiated-pcm-24bit scenario with real end-to-end test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Sendspin/conformance#60. Adds a `server-initiated-pcm-24bit` scenario that emits the 24-bit packed wire format (3-byte packed, little-endian, two's complement) and reuses the existing `audio-pcm` verification mode. The aiosendspin server feeds 32-bit source PCM into the SDK with AudioFormat(bit_depth=32) because the SDK ingests PyAV-compatible samples; the negotiated wire format from the client (24-bit) drives the output conversion to s24 via the SDK's existing s32→s24 path. Feeding raw 3-byte/sample input to prepare_audio would fail inside PyAV with "got N bytes; need 4N/3 bytes" because there is no packed-s24 sample format in PyAV. The 16→32-bit shift used here preserves the float-domain hash, so the existing audio-pcm verification matches end-to-end. The client adapter advertises bit_depth=24 at the fixture's native rate/channels so the SDK does not resample during the round trip — only the bit depth changes. Other client adapters that do not yet hardcode the new scenario_id will fail their cases until they grow the matching SupportedAudioFormat advertisement, surfacing per-SDK 24-bit decode gaps (notably sendspin-go has no 24-bit code path and sendspin-js should filter 24-bit out of its negotiation since Web Audio is float32-only). --- README.md | 1 + .../adapters/aiosendspin_client.py | 21 +++++++++- .../adapters/aiosendspin_server.py | 38 +++++++++++++++++-- src/conformance/scenarios.py | 19 ++++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e246170..1a741fa 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Current scenarios: - `server-initiated-controller` (Server initiates connection and client wants Controller): start the server first, let the client advertise a listener, let the server connect in, observe controller state, send a control command, and verify the server recorded it - `server-initiated-flac` (Server initiates connection and client wants FLAC): start the server first with PCM audio decoded from `almost_silent.flac`, let the client advertise a listener and FLAC as its only supported audio format, let the server connect in, encode the PCM to FLAC using the SDK, stream it to the client, and compare the transported FLAC bytes - `server-initiated-opus` (Server initiates connection and client wants OPUS): start the server first with PCM audio decoded from `almost_silent.flac`, let the client advertise a listener and OPUS as its only supported audio format, let the server connect in, encode the PCM to OPUS using the SDK, stream it to the client, and compare the transported OPUS bytes +- `server-initiated-pcm-24bit` (Server initiates connection and client wants 24-bit PCM): start the server first, let the client advertise a listener and 24-bit PCM as its only supported audio format, let the server connect in, negotiate the 24-bit packed wire format, stream it to the client, and compare canonical PCM hashes — a client SDK with no 24-bit decode path misreads the bytes and produces a hash mismatch ## Current coverage diff --git a/src/conformance/adapters/aiosendspin_client.py b/src/conformance/adapters/aiosendspin_client.py index 5a322d2..aa7f13f 100644 --- a/src/conformance/adapters/aiosendspin_client.py +++ b/src/conformance/adapters/aiosendspin_client.py @@ -63,7 +63,7 @@ def build_parser() -> argparse.ArgumentParser: return parser -def _supported_formats(preferred_codec: str) -> list[Any]: +def _supported_formats(preferred_codec: str, *, scenario_id: str = "") -> list[Any]: from aiosendspin.models.player import SupportedAudioFormat from aiosendspin.models.types import AudioCodec @@ -90,6 +90,18 @@ def _supported_formats(preferred_codec: str) -> list[Any]: bit_depth=16, ), ] + if scenario_id == "server-initiated-pcm-24bit": + # Match the fixture's native rate/channels so the SDK does not resample + # during the round trip; only the bit depth changes, which preserves + # the float-domain PCM hash through the 16→32→24 conversion. + return [ + SupportedAudioFormat( + codec=codec, + channels=1, + sample_rate=8_000, + bit_depth=24, + ), + ] return [ SupportedAudioFormat( codec=codec, @@ -301,11 +313,14 @@ def on_artwork_chunk(channel: int, data: bytes) -> None: if args.scenario_id in { "client-initiated-pcm", "server-initiated-pcm", + "server-initiated-pcm-24bit", "server-initiated-flac", "server-initiated-opus", }: player_support = ClientHelloPlayerSupport( - supported_formats=_supported_formats(args.preferred_codec), + supported_formats=_supported_formats( + args.preferred_codec, scenario_id=args.scenario_id + ), buffer_capacity=2_000_000, supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ) @@ -351,6 +366,7 @@ def on_artwork_chunk(channel: int, data: bytes) -> None: if args.scenario_id in { "client-initiated-pcm", "server-initiated-pcm", + "server-initiated-pcm-24bit", "server-initiated-flac", "server-initiated-opus", }: @@ -468,6 +484,7 @@ async def handle_connection(ws: Any) -> None: if args.scenario_id in { "client-initiated-pcm", "server-initiated-pcm", + "server-initiated-pcm-24bit", "server-initiated-flac", "server-initiated-opus", }: diff --git a/src/conformance/adapters/aiosendspin_server.py b/src/conformance/adapters/aiosendspin_server.py index f8bff56..fae8783 100644 --- a/src/conformance/adapters/aiosendspin_server.py +++ b/src/conformance/adapters/aiosendspin_server.py @@ -124,6 +124,24 @@ async def _wait_for_incoming_client( raise TimeoutError(f"Timed out waiting for client {client_name!r}") +def _expand_pcm_16_to_32(pcm_16_bytes: bytes) -> bytes: + """Re-pack signed 16-bit little-endian PCM as signed 32-bit little-endian. + + Each 16-bit sample becomes a 32-bit sample of equal float value by + left-shifting 16 bits — the original two bytes become the high half of the + 32-bit sample and two zero bytes fill the low half. ``FloatPcmHasher`` + produces identical float-domain hashes for the two representations, so + existing PCM verification still works. The aiosendspin SDK ingests 32-bit + PCM as the PyAV-compatible source for any non-16-bit wire format, + including the 24-bit packed wire format the SDK then writes on the wire. + """ + sample_count = len(pcm_16_bytes) // 2 + out = bytearray(sample_count * 4) + out[2::4] = pcm_16_bytes[0::2] + out[3::4] = pcm_16_bytes[1::2] + return bytes(out) + + def _iter_pcm_blocks( pcm_bytes: bytes, *, @@ -317,6 +335,17 @@ async def _run_audio_scenario(args: argparse.Namespace, *, server: Any, client: fixture, frame_samples=frame_alignment_samples, ) + wire_bit_depth = fixture.bit_depth + source_bit_depth = fixture.bit_depth + source_pcm_bytes = fixture.pcm_bytes + if args.scenario_id == "server-initiated-pcm-24bit": + # The SDK ingests source PCM as s32 (PyAV-compatible) and emits the + # negotiated 24-bit packed format on the wire. Feed 32-bit source + # bytes here and declare bit_depth=32 as the source format; the + # negotiated wire bit depth is reported via stream/start. + source_pcm_bytes = _expand_pcm_16_to_32(fixture.pcm_bytes) + source_bit_depth = 32 + wire_bit_depth = 24 stream_state: dict[str, Any] | None = None sent_codec_header_sha256: str | None = None sent_audio_hasher = sha256() @@ -388,16 +417,16 @@ def send_binary_wrapper( stream = client.group.start_stream() audio_format = AudioFormat( sample_rate=fixture.sample_rate, - bit_depth=fixture.bit_depth, + bit_depth=source_bit_depth, channels=fixture.channels, ) next_play_start_us = server.clock.now_us() + 250_000 total_duration_us = 0 for chunk, duration_us in _iter_pcm_blocks( - fixture.pcm_bytes, + source_pcm_bytes, sample_rate=fixture.sample_rate, channels=fixture.channels, - bit_depth=fixture.bit_depth, + bit_depth=source_bit_depth, ): stream.prepare_audio(chunk, audio_format) play_start_us = await stream.commit_audio(play_start_us=next_play_start_us) @@ -425,7 +454,7 @@ def send_binary_wrapper( "clip_seconds": args.clip_seconds, "sample_rate": fixture.sample_rate, "channels": fixture.channels, - "bit_depth": fixture.bit_depth, + "bit_depth": wire_bit_depth, "frame_count": fixture.frame_count, "duration_seconds": fixture.duration_seconds, "frame_alignment_samples": frame_alignment_samples, @@ -542,6 +571,7 @@ async def _scenario_payload( if args.scenario_id in { "client-initiated-pcm", "server-initiated-pcm", + "server-initiated-pcm-24bit", "server-initiated-flac", "server-initiated-opus", }: diff --git a/src/conformance/scenarios.py b/src/conformance/scenarios.py index e8a4a4f..5c30f78 100644 --- a/src/conformance/scenarios.py +++ b/src/conformance/scenarios.py @@ -141,6 +141,24 @@ ) +SERVER_INITIATED_PCM_24BIT = ScenarioSpec( + id="server-initiated-pcm-24bit", + display_name="Server initiates connection and client wants 24-bit PCM", + description=( + "Start the server first, then the client. The server emits PCM in the 24-bit " + "packed wire format (3-byte packed, little-endian, two's complement). The " + "client advertises a listener and 24-bit PCM as its only supported audio " + "format. The matrix compares canonical PCM hashes after the client unpacks " + "the 24-bit stream. A client SDK with no 24-bit decode path misreads the " + "bytes and produces a hash mismatch." + ), + initiator_role="server", + preferred_codec="pcm", + required_role_families=("player",), + verification_mode="audio-pcm", +) + + SCENARIO_LIST: tuple[ScenarioSpec, ...] = ( CLIENT_INITIATED_PCM, SERVER_INITIATED_PCM, @@ -149,6 +167,7 @@ SERVER_INITIATED_CONTROLLER, SERVER_INITIATED_FLAC, SERVER_INITIATED_OPUS, + SERVER_INITIATED_PCM_24BIT, ) SCENARIOS: dict[str, ScenarioSpec] = {scenario.id: scenario for scenario in SCENARIO_LIST}