Skip to content
Merged
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Current scenarios:
- `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
- `client-initiated-state-format-pcm` (Client changes its preferred PCM bit depth via `client/state`): start the server first, let the client connect advertising two PCM formats (24-bit then 16-bit), let the server stream the higher-priority 24-bit format, have the client send a `client/state` whose player `format` prefers the 16-bit entry, and verify the server received that preference in `client/state` and the client observed a new `stream/start` in the 16-bit format
- `client-initiated-state-format-flac` (Client changes its preferred format from PCM to FLAC via `client/state`): start the server first, let the client connect advertising PCM then FLAC, let the server stream the higher-priority PCM format, have the client send a `client/state` whose player `format` prefers the FLAC entry, and verify the server received that preference in `client/state` and the client observed a new `stream/start` in the FLAC format
- `client-initiated-metadata` (Client initiates connection and client wants Metadata): start the server first, let the client discover/connect to it, receive a metadata snapshot, and compare normalized metadata fields
- `client-initiated-artwork` (Client initiates connection and client wants Artwork): start the server first, let the client discover/connect to it, receive album artwork bytes, and compare the encoded image hash
- `client-initiated-controller` (Client initiates connection and client wants Controller): start the server first, let the client discover/connect to it, observe controller state, send a control command, and verify the server recorded it
- `server-initiated-audio-chunk-framing` (Server initiates connection and frames PCM audio chunks): start the server first, let the client advertise a listener and PCM as its only supported audio format, let the server connect in and stream, and have both adapters record each audio frame as it crossed the transport; verify that every frame is message type 4 behind the 13-byte header (type, int64 timestamp, uint32 `send_ahead`), that the client read the same 13 bytes off each one, and that no chunk lasted longer than 150 ms or, the final chunk aside, less than 15 ms. The audio itself is not compared, and `send_ahead` saturation is not judged because no adapter can observe when a frame was transmitted

## Current coverage
Expand All @@ -30,7 +33,9 @@ Current scenarios:

The OPUS scenario is currently exercised only by the `SendspinKit` and `sendspin-cpp` clients against the `aiosendspin` server until other implementations opt in via `supports_opus`.

The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). Only the `aiosendspin` client adapter drives them today.
The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). The `aiosendspin` and `SendspinKit` client adapters drive them today.

The `client-initiated-metadata`, `client-initiated-artwork` and `client-initiated-controller` scenarios have no capability flag of their own either: RC1 requires every server to support client-initiated connections, whichever roles the client brings. The `aiosendspin`, `sendspin-dotnet`, `SendspinKit` and `sendspin-cpp` client adapters drive all three over the outbound path they use for `client-initiated-pcm`. The `sendspin-js` client adapter drives the metadata and controller scenarios the same way; it declares no `artwork` role family, so the harness marks its `client-initiated-artwork` cases unsupported before launching anything. The `sendspin-go` and `sendspin-rs` client adapters have an outbound path for player sessions only, so their cases fail as a harness gap rather than being left out.

The `server-initiated-audio-chunk-framing` scenario has no capability flag of its own either. A client adapter that is handed parsed chunks and never sees a raw frame reports that, and its case fails as a harness gap rather than being left out. Only the `aiosendspin` adapters, the `sendspin-go` server and the `sendspin-rs` client observe raw frames today.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ struct CliOptions {
}

var isFormatPreferenceScenario: Bool {
scenarioID == "client-initiated-request-format-pcm"
|| scenarioID == "client-initiated-request-format-flac"
scenarioID == "client-initiated-state-format-pcm"
|| scenarioID == "client-initiated-state-format-flac"
}

/// Codecs whose conformance check compares the raw encoded chunk bytes the
Expand Down Expand Up @@ -989,8 +989,8 @@ struct ConformanceSendspinKitClient {
if options.scenarioID == "server-initiated-pcm-24bit" {
formats = try [AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8000, bitDepth: 24)]
} else if options.isFormatPreferenceScenario {
let initialDepth = options.scenarioID == "client-initiated-request-format-pcm" ? 24 : 16
let targetCodec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm
let initialDepth = options.scenarioID == "client-initiated-state-format-pcm" ? 24 : 16
let targetCodec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm
formats = try [
AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8000, bitDepth: initialDepth),
AudioFormatSpec(codec: targetCodec, channels: 1, sampleRate: 8000, bitDepth: 16),
Expand Down Expand Up @@ -1111,7 +1111,7 @@ struct ConformanceSendspinKitClient {
await collector.recordStreamFormat(format, codecHeader: await client.currentCodecHeader)
if !preferenceSent {
preferenceSent = true
let codec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm
let codec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm
let requested = try AudioFormatSpec(codec: codec, channels: 1, sampleRate: 8000, bitDepth: 16)
try await client.setPlayerFormatPreference(requested)
await collector.recordPreferenceRequest(requested)
Expand All @@ -1125,7 +1125,7 @@ struct ConformanceSendspinKitClient {
await collector.recordStreamFormat(format, codecHeader: codecHeader)
if options.isFormatPreferenceScenario, !preferenceSent {
preferenceSent = true
let targetCodec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm
let targetCodec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm
let requested = try AudioFormatSpec(codec: targetCodec, channels: 1, sampleRate: 8000, bitDepth: 16)
try await client.setPlayerFormatPreference(requested)
await collector.recordPreferenceRequest(requested)
Expand Down
6 changes: 3 additions & 3 deletions adapters/sendspin-cpp/client/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,15 @@ static bool is_player_scenario(const std::string& id) {
}

static bool is_metadata_scenario(const std::string& id) {
return id == "server-initiated-metadata";
return id == "client-initiated-metadata" || id == "server-initiated-metadata";
}

static bool is_controller_scenario(const std::string& id) {
return id == "server-initiated-controller";
return id == "client-initiated-controller" || id == "server-initiated-controller";
}

static bool is_artwork_scenario(const std::string& id) {
return id == "server-initiated-artwork";
return id == "client-initiated-artwork" || id == "server-initiated-artwork";
}

static std::string get_arg(int argc, char* argv[], const std::string& name,
Expand Down
17 changes: 17 additions & 0 deletions adapters/sendspin-go/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ func run(parsed args) int {
)
}

// The outbound path drives a player session only, so any other role would
// report a missing audio stream instead of what its scenario asks for.
if parsed.InitiatorRole == "client" && !conformance.IsPlayerScenario(parsed.ScenarioID) {
if err := conformance.WriteJSON(parsed.Ready, map[string]any{"status": "error"}); err != nil {
log.Printf("failed to write ready file: %v", err)
}
return exitWithSummary(
parsed,
errorSummary(
parsed,
fmt.Sprintf("Harness gap, not a protocol result: the sendspin-go client adapter has no client-initiated path for scenario %s", parsed.ScenarioID),
nil,
nil,
),
)
}

if parsed.InitiatorRole == "client" {
if err := conformance.WriteJSON(parsed.Ready, conformance.BuildReadyPayload(parsed.ScenarioID, parsed.InitiatorRole, "")); err != nil {
log.Printf("failed to write ready file: %v", err)
Expand Down
10 changes: 8 additions & 2 deletions adapters/sendspin-js/client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,14 @@ const PLAYER_SCENARIOS = new Set([
"server-initiated-legacy-unencrypted",
"server-initiated-audio-chunk-framing",
]);
const METADATA_SCENARIOS = new Set(["server-initiated-metadata"]);
const CONTROLLER_SCENARIOS = new Set(["server-initiated-controller"]);
const METADATA_SCENARIOS = new Set([
"client-initiated-metadata",
"server-initiated-metadata",
]);
const CONTROLLER_SCENARIOS = new Set([
"client-initiated-controller",
"server-initiated-controller",
]);

function parseArgs(argv) {
const values = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,11 @@ private suspend fun runClientInitiated(
val serverHello = client.serverHello.first { it != null }!!

if (config.scenarioId == "client-initiated-controller") {
client.controllerState.first { it != null }
// Wait for a controller state that explicitly advertises the expected command — sending
// before the server includes the command in supported_commands causes it to reject it.
while (client.controllerState.value?.supportedCommands?.contains(config.controllerCommand) != true) {
kotlinx.coroutines.delay(10)
}
client.sendControllerCommand(config.controllerCommand)
onCommandSent(config.controllerCommand)
}
Expand Down
Loading
Loading