Skip to content
Open
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 @@ -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

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 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,
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
38 changes: 34 additions & 4 deletions src/conformance/adapters/aiosendspin_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}:
Expand Down
19 changes: 19 additions & 0 deletions src/conformance/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}
Expand Down
Loading