diff --git a/README.md b/README.md index 0109e7b..53f7237 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Current scenarios: - `server-initiated-drift-injection` (Server injects timestamp drift mid-stream): start the server first, then the client. The server deliberately skews audio chunk timestamps partway through the stream; a conformant client recovers the original audio within the spec's soft/hard correction thresholds. No SDK in the matrix declares the `stream-sync-drift-correction` capability yet, so every case currently fails fast and the matrix surfaces the gap - `server-initiated-burst-cadence` (Server observes client/time burst cadence): start the server first, then the client. The server counts `client/time` messages over a fixed observation window and asserts the distribution matches the recommended cadence (bursts of 8, ~10 s apart). No SDK in the matrix declares the `client-time-burst-cadence` capability yet, so every case currently fails fast and the matrix surfaces the gap - `server-initiated-request-format` (Client requests a mid-stream codec switch): start the server first, then the client. The server begins streaming in Opus; the client is instructed to emit `stream/request-format` with FLAC at a fixed timestamp and the server responds with a fresh `stream/start`. No SDK currently emits `stream/request-format`, so every case currently fails fast on the `stream-request-format-emit` capability +- `server-initiated-pcm-24bit` (Server initiates connection and client wants 24-bit PCM): start the server first, then the client. The server re-packs the fixture as 24-bit PCM (3-byte packed, little-endian, two's complement). No SDK in the matrix declares the `pcm-24bit-decode` capability yet, so every case currently fails fast and the matrix surfaces the gap (notably the `sendspin-go` SDK has no 24-bit code path per the audit) ## Current coverage diff --git a/src/conformance/adapters/aiosendspin_client.py b/src/conformance/adapters/aiosendspin_client.py index 5a322d2..4558eed 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 doesn't resample + # — only the bit depth changes — making the float-domain PCM hash + # round-trip identically 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..984202b 100644 --- a/src/conformance/adapters/aiosendspin_server.py +++ b/src/conformance/adapters/aiosendspin_server.py @@ -124,6 +124,25 @@ 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 top half of the + 32-bit sample, and two zero bytes are inserted in the low half. + ``FloatPcmHasher`` produces identical float-domain hashes for the two + representations, so existing PCM verification still works. The aiosendspin + SDK uses 32-bit PCM as the PyAV-compatible source for any non-16-bit + target wire format, including the 24-bit packed wire format used by the + server-initiated-pcm-24bit scenario. + """ + 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, *, @@ -305,6 +324,17 @@ async def _run_audio_scenario(args: argparse.Namespace, *, server: Any, client: from aiosendspin.server.audio import AudioFormat fixture = decode_fixture(Path(args.fixture), max_duration_seconds=args.clip_seconds) + 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. Provide s32 input here + # and declare bit_depth=32 as the source format; the target bit depth + # is reported by the player negotiation in stream/start. + source_pcm_bytes = _expand_pcm_16_to_32(fixture.pcm_bytes) + source_bit_depth = 32 + wire_bit_depth = 24 frame_alignment_samples: int | None = None trimmed_source_frames = 0 if args.preferred_codec == "flac": @@ -388,16 +418,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 +455,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 +572,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/implementations.py b/src/conformance/implementations.py index 0a988dc..f335d7f 100644 --- a/src/conformance/implementations.py +++ b/src/conformance/implementations.py @@ -49,6 +49,7 @@ supports_opus=True, supports_discovery=True, supported_role_families=("player", "metadata", "controller", "artwork"), + supported_capabilities=("pcm-24bit-decode",), ), server=RoleSpec( supported=True, @@ -65,6 +66,7 @@ "stream-sync-drift-correction", "client-time-burst-cadence", "stream-request-format-emit", + "pcm-24bit-decode", ), ), ), diff --git a/src/conformance/scenarios.py b/src/conformance/scenarios.py index 83fe957..109ce14 100644 --- a/src/conformance/scenarios.py +++ b/src/conformance/scenarios.py @@ -177,6 +177,25 @@ ) +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 loads the PCM audio " + "derived from `almost_silent.flac` and re-packs it as 24-bit (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 non-conformant 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", + required_capability="pcm-24bit-decode", +) + + SERVER_INITIATED_REQUEST_FORMAT = ScenarioSpec( id="server-initiated-request-format", display_name="Client requests a mid-stream codec switch", @@ -207,6 +226,7 @@ SERVER_INITIATED_DRIFT_INJECTION, SERVER_INITIATED_BURST_CADENCE, SERVER_INITIATED_REQUEST_FORMAT, + SERVER_INITIATED_PCM_24BIT, ) SCENARIOS: dict[str, ScenarioSpec] = {scenario.id: scenario for scenario in SCENARIO_LIST}