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}