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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 19 additions & 2 deletions src/conformance/adapters/aiosendspin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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],
)
Expand Down Expand Up @@ -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",
}:
Expand Down Expand Up @@ -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",
}:
Expand Down
39 changes: 35 additions & 4 deletions src/conformance/adapters/aiosendspin_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}:
Expand Down
2 changes: 2 additions & 0 deletions src/conformance/implementations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -65,6 +66,7 @@
"stream-sync-drift-correction",
"client-time-burst-cadence",
"stream-request-format-emit",
"pcm-24bit-decode",
),
),
),
Expand Down
20 changes: 20 additions & 0 deletions src/conformance/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}
Expand Down