diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 69d6e70..0a10ac1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -72,12 +72,6 @@ jobs: with: dotnet-version: "10.0.x" - - name: Setup Java - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - name: Show Swift version run: swift --version @@ -96,7 +90,7 @@ jobs: --results-dir artifacts/results \ --build-report-path artifacts/build-report.json \ --from "aiosendspin,sendspin-server-cpp" \ - --to "aiosendspin,sendspin-cpp,sendspin-dotnet,sendspin-js,sendspin-jvm,sendspin-rs,SendspinKit" + --to "aiosendspin,sendspin-cpp,sendspin-dotnet,sendspin-js,sendspin-rs,SendspinKit" - name: Record harness outcome if: always() diff --git a/README.md b/README.md index a615dd1..286d891 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ Current scenarios: - `sendspin-cpp`: real C++ client adapter for client-initiated PCM plus the server-initiated PCM, 24-bit PCM, metadata, artwork, controller, FLAC, OPUS, legacy-unencrypted, protocol-baseline and audio-chunk-framing scenarios, evidenced from the library's message trace; it joins the `client-initiated-state-format-*` scenarios as a player but fails them, since the library exposes no way to set the `client/state` player `format`; server placeholder - `sendspin-go`: not currently tested. Both roles are placeholders: the server library has no RC1 `client/init` or Noise handshake, and the Go client adapter source in `adapters/sendspin-go/` is kept but no longer built or run - `sendspin-js`: real Node.js client adapter driving the public `SendspinCore` SDK over an adapter-owned WebSocket; covers client-initiated PCM plus the server-initiated PCM, metadata, and controller scenarios (FLAC/OPUS decode and `artwork@v1` are not exposed by the public SDK in a headless Node environment); server placeholder -- `sendspin-jvm`: real JVM client adapter written in Kotlin, for client-initiated PCM plus the server-initiated PCM, metadata, artwork, controller, and FLAC scenarios; server placeholder - `sendspin-rs`: real Rust client adapter for client-initiated PCM plus the server-initiated PCM, metadata, artwork, controller, and FLAC scenarios; server placeholder - `sendspin-server-cpp`: real C++ server adapter for the client- and server-initiated PCM, 24-bit PCM, protocol-baseline, audio-chunk-framing, metadata, artwork and controller scenarios, the server-initiated FLAC and OPUS scenarios (both encoded by the library) and both `client-initiated-state-format-*` scenarios, built on the library's public API and evidenced from its message trace; the library has no unencrypted mode, so it takes no part in `server-initiated-legacy-unencrypted`; client placeholder (its client counterpart is `sendspin-cpp`) diff --git a/adapters/sendspin-jvm/client/build.gradle.kts b/adapters/sendspin-jvm/client/build.gradle.kts deleted file mode 100644 index eafa57b..0000000 --- a/adapters/sendspin-jvm/client/build.gradle.kts +++ /dev/null @@ -1,30 +0,0 @@ -plugins { - kotlin("jvm") -} - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -kotlin { - jvmToolchain(17) -} - -tasks.jar { - archiveBaseName.set("conformance-client") - archiveVersion.set("") - manifest { - attributes["Main-Class"] = "com.sendspin.conformance.MainKt" - } - from(provider { configurations.runtimeClasspath.get().files.map { if (it.isDirectory) it else zipTree(it) } }) - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -dependencies { - implementation(project(":sendspin-protocol")) - implementation(libs.moshi.kotlin) - implementation(libs.okhttp) - implementation(libs.coroutines.core) - implementation(libs.json) -} diff --git a/adapters/sendspin-jvm/client/settings.gradle.kts b/adapters/sendspin-jvm/client/settings.gradle.kts deleted file mode 100644 index 31a782c..0000000 --- a/adapters/sendspin-jvm/client/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - plugins { - kotlin("jvm") version "2.0.0" - } - repositories { - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - mavenCentral() - } - versionCatalogs { - create("libs") { - from(files("../../../repos/sendspin-jvm/gradle/libs.versions.toml")) - } - } -} - -rootProject.name = "sendspin-jvm-conformance-client" - -include(":sendspin-protocol") -project(":sendspin-protocol").projectDir = file("../../../repos/sendspin-jvm/sendspin-protocol") diff --git a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/CollectingAudioPlayer.kt b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/CollectingAudioPlayer.kt deleted file mode 100644 index 976ed36..0000000 --- a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/CollectingAudioPlayer.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.sendspin.conformance - -import com.sendspin.protocol.AudioBuffer -import com.sendspin.protocol.AudioPlayer -import com.sendspin.protocol.StreamFormat - -/** - * Audio player for the conformance CLI. Does not play audio. - * - * Actual chunk collection is done via [com.sendspin.protocol.SendSpinClient.onAudioChunk], - * which bypasses [AudioBuffer] drop-logic. This class exists only to satisfy the - * [AudioPlayer] interface required by [com.sendspin.protocol.SendSpinClient]. - */ -class CollectingAudioPlayer(val audioBuffer: AudioBuffer) : AudioPlayer { - // Always true so SendSpinClient takes the transition() path on subsequent stream/start - // messages rather than configure()+start(), which in a real player would flush buffers. - // Chunks are collected via SendSpinClient.onAudioChunk outside the normal lifecycle. - override val isPlaying: Boolean = true - override val droppedDecodeFrames: Long = 0L - - var format: StreamFormat? = null - private set - - override fun configure(format: StreamFormat) { this.format = format } - override fun start() {} - override fun flush() {} - override fun stop() {} - override fun transition(format: StreamFormat) { this.format = format } - override fun setVolume(gain: Float) {} -} diff --git a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt deleted file mode 100644 index d9057b3..0000000 --- a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt +++ /dev/null @@ -1,633 +0,0 @@ -package com.sendspin.conformance - -import com.sendspin.protocol.ArtworkChannel -import com.sendspin.protocol.AudioChunk -import com.sendspin.protocol.AudioFormat -import com.sendspin.protocol.ClientPreferences -import com.sendspin.protocol.ClientState -import com.sendspin.protocol.ControllerState -import com.sendspin.protocol.JsonOptional -import com.sendspin.protocol.JsonOptionalAdapterFactory -import com.sendspin.protocol.SendSpinClient -import com.sendspin.protocol.SendSpinServerHost -import com.sendspin.protocol.ServerHello -import com.sendspin.protocol.StreamArtworkConfig -import com.sendspin.protocol.StreamFormat -import com.sendspin.protocol.TrackMetadataMsg -import com.squareup.moshi.Moshi -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import okhttp3.OkHttpClient -import org.json.JSONArray -import org.json.JSONObject -import java.io.ByteArrayOutputStream -import java.io.File -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.security.MessageDigest -import java.util.UUID -import java.util.logging.ConsoleHandler -import java.util.logging.Level -import java.util.logging.Logger -import kotlin.system.exitProcess - -// ── Config ──────────────────────────────────────────────────────────────────── - -data class CliConfig( - val scenarioId: String, - val initiatorRole: String, // "server" or "client" - val preferredCodec: String, - val summaryPath: String, - val readyPath: String, - val registryPath: String, - val clientName: String, - val clientId: String, - val serverName: String, - val serverId: String, - val listenerPort: Int?, // server-initiated: our listener port - val wsPath: String, // WebSocket path, default "/sendspin" - val controllerCommand: String, // command to send for controller scenarios - val timeoutMs: Long, -) - -// ── Session result ──────────────────────────────────────────────────────────── - -data class SessionResult( - val serverHello: ServerHello, - val streamFormat: StreamFormat?, - val artworkStreamConfig: StreamArtworkConfig?, - val encodedBytes: ByteArray, - val chunkCount: Int, - val lastMetadata: TrackMetadataMsg?, - val metadataUpdateCount: Int, - val controllerState: ControllerState?, - val sentControllerCommand: String?, - val artworkBytes: ByteArray?, - val artworkChannel: Int, - val artworkReceivedCount: Int, - val renegotiation: RenegotiationResult?, -) - -// ── stream/request-format renegotiation ────────────────────────────────────── - -/** The format the client asks for via `stream/request-format`. */ -data class RequestedFormat( - val codec: String?, - val channels: Int?, - val sampleRate: Int?, - val bitDepth: Int?, -) - -data class RenegotiationResult( - val streamStartCount: Int, - val requested: RequestedFormat, - val initialFormat: StreamFormat?, - val finalFormat: StreamFormat?, -) - -private fun isRequestFormatScenario(scenarioId: String) = - scenarioId == "client-initiated-request-format-pcm" || scenarioId == "client-initiated-request-format-flac" - -/** - * The formats a renegotiation scenario advertises in client/hello (priority order) and the - * format it later requests via stream/request-format. The server starts on the first - * advertised format, then switches to the requested one. - */ -private fun requestFormatPlan(scenarioId: String): Pair, RequestedFormat> { - fun pcm(bitDepth: Int) = AudioFormat("pcm", 1, 8000, bitDepth) - if (scenarioId == "client-initiated-request-format-flac") { - val flac = AudioFormat("flac", 1, 8000, 16) - return listOf(pcm(16), flac) to RequestedFormat("flac", 1, 8000, 16) - } - // Default: PCM bit-depth downgrade (24-bit preferred, request 16-bit). - return listOf(pcm(24), pcm(16)) to RequestedFormat("pcm", 1, 8000, 16) -} - -/** - * Waits for the initial stream/start, requests [requested] via stream/request-format, then - * waits for the server to re-emit stream/start with the renegotiated format. - */ -private suspend fun runRequestFormatRenegotiation( - client: SendSpinClient, - requested: RequestedFormat, -): RenegotiationResult { - var streamStartCount = 0 - var initialFormat: StreamFormat? = null - var finalFormat: StreamFormat? = null - - client.streamFormat.filterNotNull().take(2).collect { fmt -> - streamStartCount++ - if (streamStartCount == 1) { - initialFormat = fmt - client.requestPlayerFormat( - codec = requested.codec, - channels = requested.channels, - sampleRate = requested.sampleRate, - bitDepth = requested.bitDepth, - ) - } else { - finalFormat = fmt - } - } - - return RenegotiationResult( - streamStartCount = streamStartCount, - requested = requested, - initialFormat = initialFormat, - finalFormat = finalFormat, - ) -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -fun main(args: Array) { - val julHandler = ConsoleHandler().apply { - level = Level.FINE - formatter = java.util.logging.SimpleFormatter() - } - Logger.getLogger("sendspin-protocol").apply { - addHandler(julHandler); level = Level.FINE; useParentHandlers = false - } - - val config = parseArgs(args) - val moshi = Moshi.Builder() - .add(JsonOptionalAdapterFactory()) - .addLast(KotlinJsonAdapterFactory()) - .build() - - var artworkReceivedCount = 0 - var metadataUpdateCount = 0 - var lastMetadata: TrackMetadataMsg? = null - var lastControllerState: ControllerState? = null - var lastStreamFormat: StreamFormat? = null - var sentCommand: String? = null - var renegotiationResult: RenegotiationResult? = null - - // Collect all raw audio chunks via the hook — bypasses AudioBuffer drop logic so early - // chunks (arriving before clock sync converges) are not lost. - val receivedChunks = mutableListOf() - - val client = SendSpinClient( - okHttpClient = OkHttpClient.Builder().build(), - moshi = moshi, - preferences = if (isRequestFormatScenario(config.scenarioId)) { - ClientPreferences( - supportedFormats = requestFormatPlan(config.scenarioId).first, - artworkChannels = listOf(ArtworkChannel("album", "jpeg", 256, 256)), - ) - } else { - buildPreferences(config.preferredCodec) - }, - clientId = config.clientId, - clientName = config.clientName, - manufacturer = "conformance", - productName = "conformance-client", - softwareVersion = "1.0", - audioPlayerFactory = { buffer, _ -> CollectingAudioPlayer(buffer) }, - reconnectEnabled = false, - ).also { it.onAudioChunk = { chunk -> synchronized(receivedChunks) { receivedChunks.add(chunk) } } } - - val sessionResult = runBlocking { - // Collectors for data that require active observation — must be started before the session - // so we capture values that cleanupJobs() will null out after disconnect. - val metaJob: Job = launch { - client.serverState.collect { state -> - state.metadata.orNull()?.let { lastMetadata = it; metadataUpdateCount++ } - } - } - val artworkJob: Job = launch { - client.albumArtwork.collect { bytes -> - if (bytes != null) artworkReceivedCount++ - } - } - val controllerJob: Job = launch { - client.controllerState.collect { cs -> if (cs != null) lastControllerState = cs } - } - val streamFormatJob: Job = launch { - client.streamFormat.collect { fmt -> if (fmt != null) lastStreamFormat = fmt } - } - - val result = withTimeoutOrNull(config.timeoutMs) { - when (config.initiatorRole) { - "server" -> runServerInitiated(client, moshi, config) { sentCommand = it } - "client" -> runClientInitiated(client, config, { sentCommand = it }) { renegotiationResult = it } - else -> error("Unknown initiator_role: ${config.initiatorRole}") - } - } - - metaJob.cancelAndJoin() - artworkJob.cancelAndJoin() - controllerJob.cancelAndJoin() - streamFormatJob.cancelAndJoin() - - result - } - - if (sessionResult == null) { - writeSummaryAndExit( - config, moshi, - buildErrorSummary(config, "timeout after ${config.timeoutMs / 1000}s"), - exitCode = 1, - ) - } - - val chunks = synchronized(receivedChunks) { receivedChunks.toList() } - val encodedBytes = ByteArrayOutputStream().also { out -> chunks.forEach { out.write(it.data) } }.toByteArray() - val session = SessionResult( - serverHello = sessionResult!!, - streamFormat = lastStreamFormat, - artworkStreamConfig = client.streamArtwork.value, - encodedBytes = encodedBytes, - chunkCount = chunks.size, - lastMetadata = lastMetadata, - metadataUpdateCount = metadataUpdateCount, - controllerState = lastControllerState, - sentControllerCommand = sentCommand, - artworkBytes = client.albumArtwork.value, - artworkChannel = 0, - artworkReceivedCount = artworkReceivedCount, - renegotiation = renegotiationResult, - ) - - writeSummaryAndExit(config, moshi, buildSummary(config, session, moshi), exitCode = 0) -} - -// ── Session runners ─────────────────────────────────────────────────────────── - -private suspend fun runServerInitiated( - client: SendSpinClient, - moshi: Moshi, - config: CliConfig, - onCommandSent: (String) -> Unit, -): ServerHello { - val port = requireNotNull(config.listenerPort) { "--port is required for server-initiated scenarios" } - val url = "ws://127.0.0.1:$port${config.wsPath}" - - val host = SendSpinServerHost(client = client, moshi = moshi, getLastPlayedServerId = { "" }, port = port) - host.startServer() - host.serverReady.await() - - // Register in registry and signal ready - writeRegistry(config.registryPath, config.clientName, url) - writeReadyFile(config.readyPath, config.scenarioId, config.initiatorRole, url) - - val serverHello = client.serverHello.first { it != null }!! - - if (config.scenarioId == "server-initiated-controller") { - // 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) - - // Give the server up to 5 s to close the connection; if it doesn't, close from our side. - val disconnected = withTimeoutOrNull(5_000) { - client.state.first { it == ClientState.DISCONNECTED || it == ClientState.ERROR } - } - if (disconnected == null) client.disconnect("conformance_complete") - host.stopServer() - return serverHello - } - - client.state.first { it == ClientState.DISCONNECTED || it == ClientState.ERROR } - host.stopServer() - return serverHello -} - -private suspend fun runClientInitiated( - client: SendSpinClient, - config: CliConfig, - onCommandSent: (String) -> Unit, - onRenegotiation: (RenegotiationResult) -> Unit, -): ServerHello { - val serverUrl = pollRegistry(config.registryPath, config.serverName, timeoutMs = config.timeoutMs) - ?: error("Server did not register in registry within timeout") - - writeReadyFile(config.readyPath, config.scenarioId, config.initiatorRole) - client.connect(serverUrl) - - val serverHello = client.serverHello.first { it != null }!! - - if (config.scenarioId == "client-initiated-controller") { - // 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) - } - - if (isRequestFormatScenario(config.scenarioId)) { - val (_, requested) = requestFormatPlan(config.scenarioId) - onRenegotiation(runRequestFormatRenegotiation(client, requested)) - } - - client.state.first { it == ClientState.DISCONNECTED || it == ClientState.ERROR } - return serverHello -} - -// ── Summary building ────────────────────────────────────────────────────────── - -private fun buildSummary(config: CliConfig, session: SessionResult, moshi: Moshi): JSONObject { - val helloAdapter = moshi.adapter(ServerHello::class.java) - - return JSONObject().apply { - put("status", "ok") - put("implementation", "sendspin-jvm") - put("role", "client") - put("client_name", config.clientName) - put("client_id", config.clientId) - put("scenario_id", config.scenarioId) - put("initiator_role", config.initiatorRole) - put("preferred_codec", config.preferredCodec) - put("peer_hello", JSONObject(helloAdapter.toJson(session.serverHello))) - put("server", JSONObject.NULL) - - when { - config.scenarioId.contains("-metadata") -> addMetadataFields(session, moshi) - config.scenarioId.contains("-controller") -> addControllerFields(session) - config.scenarioId.contains("-artwork") -> addArtworkFields(session) - isRequestFormatScenario(config.scenarioId) -> addRenegotiationFields(session) - else -> { - addAudioFields(session, config.preferredCodec) - if (config.scenarioId == "server-initiated-audio-chunk-framing") { - // SendSpinClient parses the header off each frame before the chunk - // reaches onAudioChunk, so the adapter never holds a raw frame. - getJSONObject("audio").apply { - put("received_encoded_byte_count", session.encodedBytes.size) - put("received_chunk_frames", JSONObject.NULL) - } - } - } - } - } -} - -private fun JSONObject.addRenegotiationFields(session: SessionResult) { - val reneg = session.renegotiation - put("renegotiation", JSONObject().apply { - put("stream_start_count", reneg?.streamStartCount ?: 0) - put("requested", JSONObject().apply { - put("codec", reneg?.requested?.codec ?: JSONObject.NULL) - put("channels", reneg?.requested?.channels ?: JSONObject.NULL) - put("sample_rate", reneg?.requested?.sampleRate ?: JSONObject.NULL) - put("bit_depth", reneg?.requested?.bitDepth ?: JSONObject.NULL) - }) - put("initial_format", reneg?.initialFormat?.let { streamFormatJson(it) } ?: JSONObject.NULL) - put("final_format", reneg?.finalFormat?.let { streamFormatJson(it) } ?: JSONObject.NULL) - }) -} - -private fun streamFormatJson(fmt: StreamFormat) = JSONObject().apply { - put("codec", fmt.codec) - put("sample_rate", fmt.sampleRate) - put("channels", fmt.channels) - put("bit_depth", fmt.bitDepth) -} - -private fun JSONObject.addAudioFields(session: SessionResult, codec: String) { - val fmt = session.streamFormat - if (fmt != null) { - put("stream", JSONObject().apply { - put("codec", fmt.codec) - put("sample_rate", fmt.sampleRate) - put("channels", fmt.channels) - put("bit_depth", fmt.bitDepth) - put("codec_header", fmt.codecHeader ?: JSONObject.NULL) - }) - } - val encodedHash = sha256Hex(session.encodedBytes) - // PCM hash uses canonical float32 normalization (matches conformance harness pcm.py) - val pcmHash = if (codec == "pcm" && fmt != null) { - sha256Hex(pcmToFloat32Bytes(session.encodedBytes, fmt.bitDepth)) - } else { - JSONObject.NULL - } - val sampleCount = if (fmt != null) session.encodedBytes.size / (fmt.bitDepth / 8) else 0 - put("audio", JSONObject().apply { - put("audio_chunk_count", session.chunkCount) - put("received_encoded_sha256", encodedHash) - put("received_pcm_sha256", pcmHash) - put("received_sample_count", sampleCount) - }) -} - -private fun JSONObject.addMetadataFields(session: SessionResult, moshi: Moshi) { - val meta = session.lastMetadata - put("metadata", JSONObject().apply { - put("update_count", session.metadataUpdateCount) - if (meta != null) { - put("received", JSONObject().apply { - put("title", meta.title.orNull()) - put("artist", meta.artist.orNull()) - put("album_artist", meta.albumArtist.orNull()) - put("album", meta.album.orNull()) - put("artwork_url", meta.artworkUrl.orNull()) - put("year", meta.year.orNull() ?: JSONObject.NULL) - put("track", meta.track.orNull() ?: JSONObject.NULL) - val prog = meta.progress - if (prog != null) { - put("progress", JSONObject().apply { - put("track_progress", prog.trackProgress) - put("track_duration", prog.trackDuration) - put("playback_speed", prog.playbackSpeed) - }) - } else { - put("progress", JSONObject.NULL) - } - }) - } else { - put("received", JSONObject.NULL) - } - }) -} - -private fun JSONObject.addControllerFields(session: SessionResult) { - put("controller", JSONObject().apply { - val cs = session.controllerState - if (cs != null) { - put("received_state", JSONObject().apply { - put("supported_commands", JSONArray(cs.supportedCommands)) - put("volume", cs.volume) - put("muted", cs.muted) - put("repeat", cs.repeat.orNull() ?: JSONObject.NULL) - put("shuffle", cs.shuffle.orNull() ?: JSONObject.NULL) - }) - } else { - put("received_state", JSONObject.NULL) - } - val cmd = session.sentControllerCommand - if (cmd != null) { - put("sent_command", JSONObject().put("command", cmd)) - } else { - put("sent_command", JSONObject.NULL) - } - }) -} - -private fun JSONObject.addArtworkFields(session: SessionResult) { - val cfg = session.artworkStreamConfig - if (cfg != null) { - put("stream", JSONObject().apply { - put("channels", JSONArray().apply { - cfg.channels.forEach { ch -> - put(JSONObject().apply { - put("source", ch.source) - put("format", ch.format) - put("width", ch.width) - put("height", ch.height) - }) - } - }) - }) - } - val bytes = session.artworkBytes - put("artwork", JSONObject().apply { - put("channel", session.artworkChannel) - put("received_count", session.artworkReceivedCount) - put("received_sha256", if (bytes != null) sha256Hex(bytes) else JSONObject.NULL) - put("byte_count", bytes?.size ?: 0) - }) -} - -private fun buildErrorSummary(config: CliConfig, reason: String) = JSONObject().apply { - put("status", "error") - put("implementation", "sendspin-jvm") - put("role", "client") - put("client_name", config.clientName) - put("client_id", config.clientId) - put("scenario_id", config.scenarioId) - put("initiator_role", config.initiatorRole) - put("preferred_codec", config.preferredCodec) - put("reason", reason) -} - -private fun writeSummaryAndExit(config: CliConfig, moshi: Moshi, summary: JSONObject, exitCode: Int): Nothing { - val text = summary.toString(2) + "\n" - File(config.summaryPath).apply { parentFile?.mkdirs(); writeText(text) } - print(text) - exitProcess(exitCode) -} - -// ── Registry / ready helpers ────────────────────────────────────────────────── - -private fun writeRegistry(registryPath: String, name: String, url: String) { - val file = File(registryPath) - // Tolerate a partially-written file from a concurrent writer — treat it as empty. - val payload = if (file.exists()) try { JSONObject(file.readText()) } catch (_: Exception) { JSONObject() } else JSONObject() - payload.put(name, JSONObject().put("url", url)) - file.apply { parentFile?.mkdirs(); writeText(payload.toString(2) + "\n") } -} - -private suspend fun pollRegistry(registryPath: String, serverName: String, timeoutMs: Long): String? { - val deadline = System.currentTimeMillis() + timeoutMs - while (System.currentTimeMillis() < deadline) { - val url = withContext(Dispatchers.IO) { - val file = File(registryPath) - if (file.exists()) try { - JSONObject(file.readText()).optJSONObject(serverName)?.optString("url") - } catch (_: Exception) { null } // partial write race — retry on next poll - else null - } - if (!url.isNullOrBlank()) return url - delay(100) - } - return null -} - -private fun writeReadyFile(readyPath: String, scenarioId: String, initiatorRole: String, url: String? = null) { - val json = JSONObject().apply { - put("status", "ready") - put("scenario_id", scenarioId) - put("initiator_role", initiatorRole) - if (url != null) put("url", url) - } - File(readyPath).apply { parentFile?.mkdirs(); writeText(json.toString(2) + "\n") } -} - -// ── Utilities ───────────────────────────────────────────────────────────────── - -private fun buildPreferences(preferredCodec: String) = ClientPreferences( - // Request source-compatible formats first so the server sends without resampling. - // The almost_silent.flac fixture is 8 kHz mono; 256×256 is its embedded artwork. - supportedFormats = when (preferredCodec) { - "flac" -> listOf(AudioFormat("flac", 1, 8000, 16), AudioFormat("flac", 2, 48000, 16)) - // Every player has to list flac or pcm; opus stays first so the server's - // priority choice is what the case exercises. - "opus" -> listOf( - AudioFormat("opus", 1, 48000, 16), - AudioFormat("opus", 2, 48000, 16), - AudioFormat("pcm", 1, 48000, 16), - ) - else -> listOf(AudioFormat("pcm", 1, 8000, 16), AudioFormat("pcm", 2, 48000, 16)) - }, - artworkChannels = listOf(ArtworkChannel("album", "jpeg", 256, 256)), -) - -/** - * Converts integer PCM bytes to canonical float32 bytes, matching conformance/pcm.py's - * `pcm_int_bytes_to_float_bytes`. Required for `received_pcm_sha256` to match the harness. - */ -private fun pcmToFloat32Bytes(pcmBytes: ByteArray, bitDepth: Int): ByteArray { - val sampleCount = pcmBytes.size / (bitDepth / 8) - val result = ByteBuffer.allocate(sampleCount * 4).order(ByteOrder.LITTLE_ENDIAN) - val src = ByteBuffer.wrap(pcmBytes).order(ByteOrder.LITTLE_ENDIAN) - when (bitDepth) { - 16 -> repeat(sampleCount) { result.putFloat(src.short / 32768.0f) } - 32 -> repeat(sampleCount) { result.putFloat(src.int / 2147483648.0f) } - else -> throw IllegalArgumentException("Unsupported PCM bit depth: $bitDepth") - } - return result.array() -} - -private fun sha256Hex(bytes: ByteArray): String { - if (bytes.isEmpty()) return "" - return MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } -} - -private fun JsonOptional.orNull(): T? = (this as? JsonOptional.Present)?.value - -private fun parseArgs(args: Array): CliConfig { - val map = mutableMapOf() - var i = 0 - while (i < args.size) { - if (!args[i].startsWith("--")) { i++; continue } - val key = args[i].removePrefix("--") - val value = args.getOrNull(i + 1)?.takeIf { !it.startsWith("--") } ?: "" - map[key] = value - i += if (value.isEmpty()) 1 else 2 - } - val scenarioId = map["scenario-id"] ?: error("--scenario-id is required") - val initiatorRole = map["initiator-role"] - ?: if (scenarioId.startsWith("server-initiated")) "server" else "client" - return CliConfig( - scenarioId = scenarioId, - initiatorRole = initiatorRole, - preferredCodec = map["preferred-codec"] ?: "pcm", - summaryPath = map["summary"] ?: error("--summary is required"), - readyPath = map["ready"] ?: error("--ready is required"), - registryPath = map["registry"] ?: error("--registry is required"), - clientName = map["client-name"] ?: "sendspin-android-tv", - clientId = map["client-id"] ?: UUID.randomUUID().toString(), - serverName = map["server-name"] ?: "", - serverId = map["server-id"] ?: "", - listenerPort = map["port"]?.toIntOrNull(), - wsPath = map["path"]?.takeIf { it.isNotBlank() } ?: "/sendspin", - controllerCommand = map["controller-command"]?.takeIf { it.isNotBlank() } ?: "volume", - timeoutMs = map["timeout-seconds"]?.toDoubleOrNull()?.let { (it * 1000).toLong() } ?: 30_000, - ) -} diff --git a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/NoOpNsdBrowser.kt b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/NoOpNsdBrowser.kt deleted file mode 100644 index 118d454..0000000 --- a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/NoOpNsdBrowser.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.sendspin.conformance - -import com.sendspin.protocol.NsdBrowser -import com.sendspin.protocol.NsdServiceEvent -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow - -/** No-op mDNS browser for the conformance CLI. Discovery is not used — the harness provides URLs. */ -class NoOpNsdBrowser : NsdBrowser { - override fun browse(serviceType: String): Flow = emptyFlow() -} diff --git a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/NoOpNsdRegistrar.kt b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/NoOpNsdRegistrar.kt deleted file mode 100644 index 41983ad..0000000 --- a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/NoOpNsdRegistrar.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.sendspin.conformance - -import com.sendspin.protocol.NsdRegistrar -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -/** - * No-op mDNS registrar for the conformance CLI. The harness assigns ports directly. - * - * Uses [callbackFlow] so the flow stays open until cancelled, matching the contract of - * [NsdRegistrar.register] (cancellation = unregister). [flowOf] would complete immediately - * and terminate callers' collect blocks prematurely. - */ -class NoOpNsdRegistrar : NsdRegistrar { - override fun register( - serviceName: String, - serviceType: String, - port: Int, - attributes: Map, - ): Flow = callbackFlow { - trySend(Unit) - awaitClose { } - } -} diff --git a/src/conformance/adapters/sendspin_jvm_client.py b/src/conformance/adapters/sendspin_jvm_client.py deleted file mode 100644 index 05a53ad..0000000 --- a/src/conformance/adapters/sendspin_jvm_client.py +++ /dev/null @@ -1,37 +0,0 @@ -"""sendspin-jvm client adapter for the conformance harness.""" - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -from conformance.paths import repo_root -from conformance.toolchains import find_java - - -def _jar_path() -> Path: - return repo_root() / "adapters" / "sendspin-jvm" / "client" / "build" / "libs" / "conformance-client.jar" - - -def main() -> None: - jar = _jar_path() - if not jar.exists(): - print( - f"conformance-client.jar not found at {jar}\n" - "Build it first: conformance build", - file=sys.stderr, - ) - sys.exit(1) - - java = find_java() - if java is None: - print("No java executable found. Set JAVA_HOME or add java to PATH.", file=sys.stderr) - sys.exit(1) - - result = subprocess.run([java, "-jar", str(jar)] + sys.argv[1:], check=False) - sys.exit(result.returncode) - - -if __name__ == "__main__": - main() diff --git a/src/conformance/build.py b/src/conformance/build.py index 6d9bcda..8ac62bb 100644 --- a/src/conformance/build.py +++ b/src/conformance/build.py @@ -16,7 +16,7 @@ from .implementations import ensure_repo_checkout, resolve_repo_path from .io import write_json from .paths import repo_root -from .toolchains import find_cargo, find_cmake, find_dotnet, find_java, find_swift +from .toolchains import find_cargo, find_cmake, find_dotnet, find_swift BuildResult = dict[str, Any] @@ -274,41 +274,6 @@ def _swift_build_result() -> BuildResult: ) -def _gradle_build_result() -> BuildResult: - try: - repo = ensure_repo_checkout("sendspin-jvm") - except FileNotFoundError as err: - return { - "adapter": "sendspin-jvm-client", - "status": "failed", - "detail": str(err), - } - - java = find_java() - if java is None: - return { - "adapter": "sendspin-jvm-client", - "status": "failed", - "detail": "java executable is not available", - } - - gradlew = repo / ("gradlew.bat" if os.name == "nt" else "gradlew") - adapter_dir = repo_root() / "adapters" / "sendspin-jvm" / "client" - jar = adapter_dir / "build" / "libs" / "conformance-client.jar" - completed = _run_command( - [str(gradlew), "--project-dir", str(adapter_dir), "jar"], - cwd=repo, - ) - runtime_command_prefix = None - if completed.returncode == 0 and jar.exists(): - runtime_command_prefix = [java, "-jar", str(jar)] - return _built_result( - adapter="sendspin-jvm-client", - completed=completed, - runtime_command_prefix=runtime_command_prefix, - ) - - def _cmake_build_result( *, adapter: str, @@ -373,7 +338,6 @@ def build_adapters(report_path: Path | None = None) -> list[BuildResult]: def _build_plan() -> BuildPlan: return ( ("python-adapters", _python_build_result), - ("sendspin-jvm-client", _gradle_build_result), ("sendspin-dotnet-client", _dotnet_build_result), ("sendspin-rs-client", _cargo_build_result), ("SendspinKit-client", _swift_build_result), diff --git a/src/conformance/implementations.py b/src/conformance/implementations.py index 87cd28c..c071cfb 100644 --- a/src/conformance/implementations.py +++ b/src/conformance/implementations.py @@ -15,31 +15,6 @@ IMPLEMENTATIONS: dict[str, ImplementationSpec] = { - "sendspin-jvm": ImplementationSpec( - name="sendspin-jvm", - display_name="sendspin-jvm", - repo_dirname="sendspin-jvm", - remote_url="https://github.com/Sendspin/sendspin-jvm.git", - client=RoleSpec( - supported=True, - adapter_kind="python", - build_adapter="sendspin-jvm-client", - entrypoint="conformance.adapters.sendspin_jvm_client", - supports_server_initiated=True, - supports_client_initiated=True, - supports_flac=True, - supports_opus=False, - supports_discovery=False, - supports_legacy_unencrypted=True, - supported_role_families=("player", "metadata", "controller", "artwork"), - ), - server=RoleSpec( - supported=False, - adapter_kind="placeholder", - entrypoint="conformance.adapters.placeholder", - reason="sendspin-jvm is a client-only implementation.", - ), - ), "aiosendspin": ImplementationSpec( name="aiosendspin", display_name="aiosendspin", diff --git a/src/conformance/toolchains.py b/src/conformance/toolchains.py index 7f82a96..27fa68f 100644 --- a/src/conformance/toolchains.py +++ b/src/conformance/toolchains.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os import shutil from pathlib import Path @@ -37,13 +36,3 @@ def find_go() -> str | None: def find_cmake() -> str | None: """Return a usable cmake executable path.""" return shutil.which("cmake") - - -def find_java() -> str | None: - """Return a usable java executable path.""" - java_home = os.environ.get("JAVA_HOME") - if java_home: - candidate = Path(java_home) / "bin" / "java" - if candidate.exists(): - return str(candidate) - return shutil.which("java") diff --git a/tests/test_failure_reasons.py b/tests/test_failure_reasons.py index 908071f..05261e3 100644 --- a/tests/test_failure_reasons.py +++ b/tests/test_failure_reasons.py @@ -222,10 +222,15 @@ def test_a_dependency_named_after_errors_is_not_a_diagnostic(self) -> None: ) def test_falls_back_to_the_first_line_without_a_diagnostic(self) -> None: - detail = "Unable to locate a Java Runtime.\nPlease visit http://www.java.com\n" + detail = ( + "xcode-select: note: No developer tools were found, requesting install.\n" + "If developer tools are located at a non-default location on disk, use " + "`sudo xcode-select --switch path`.\n" + ) self.assertEqual( _build_failure_reason({"adapter": "x-client", "status": "failed", "detail": detail}), - "x-client build failed: Unable to locate a Java Runtime.", + "x-client build failed: xcode-select: note: No developer tools were found, " + "requesting install.", ) def test_says_so_when_no_detail_was_recorded(self) -> None: diff --git a/tests/test_regression_baseline.py b/tests/test_regression_baseline.py index 89dd05a..18f6ea7 100644 --- a/tests/test_regression_baseline.py +++ b/tests/test_regression_baseline.py @@ -37,7 +37,7 @@ def _row( status: str, scenario_id: str = "server-initiated-flac", server_impl: str = "aiosendspin", - client_impl: str = "sendspin-jvm", + client_impl: str = "sendspin-dotnet", scenario_revision: int | None = 1, ) -> dict[str, Any]: """Build one index.json result row, omitting the revision when it is None.""" @@ -175,11 +175,11 @@ def test_the_exemption_is_per_scenario(self) -> None: def test_a_stamped_baseline_still_protects_a_row_that_lost_its_revision(self) -> None: """A baseline that stamps revisions must not be disarmed by one unstamped row.""" baseline = [ - _row(client_impl="sendspin-jvm", status="passed", scenario_revision=None), + _row(client_impl="sendspin-dotnet", status="passed", scenario_revision=None), _row(client_impl="sendspin-rs", status="passed", scenario_revision=1), ] current = [ - _row(client_impl="sendspin-jvm", status="failed", scenario_revision=1), + _row(client_impl="sendspin-dotnet", status="failed", scenario_revision=1), _row(client_impl="sendspin-rs", status="failed", scenario_revision=1), ] self.assertEqual(len(detect_regressions(baseline, current)), 2) @@ -188,11 +188,11 @@ def test_a_stamped_baseline_still_protects_a_row_that_lost_its_revision(self) -> def test_the_exemption_is_per_cell_within_a_scenario(self) -> None: """Cells are keyed by implementation pair, so one pair cannot mask another.""" baseline = [ - _row(client_impl="sendspin-jvm", status="passed", scenario_revision=1), + _row(client_impl="sendspin-dotnet", status="passed", scenario_revision=1), _row(client_impl="sendspin-rs", status="passed", scenario_revision=1), ] current = [ - _row(client_impl="sendspin-jvm", status="failed", scenario_revision=2), + _row(client_impl="sendspin-dotnet", status="failed", scenario_revision=2), _row(client_impl="sendspin-rs", status="failed", scenario_revision=1), ] regressions = detect_regressions(baseline, current) @@ -237,9 +237,9 @@ def test_a_cell_with_no_passing_baseline_is_not_listed(self) -> None: def test_the_summary_counts_cells_and_names_each_scenario(self) -> None: cells = [ - ("server-initiated-flac", "sendspin-jvm"), + ("server-initiated-flac", "sendspin-dotnet"), ("server-initiated-flac", "sendspin-rs"), - ("server-initiated-pcm", "sendspin-jvm"), + ("server-initiated-pcm", "sendspin-dotnet"), ] exempt = exempt_cases( [ @@ -322,7 +322,7 @@ def test_a_case_result_carries_its_scenario_revision(self) -> None: environment_name="macOS", scenario=scenario, server_impl="aiosendspin", - client_impl="sendspin-jvm", + client_impl="sendspin-dotnet", case_dir=Path("results/data/case"), ) result = _case_result(context, status="failed", reason="synthetic") diff --git a/tests/test_repo_override.py b/tests/test_repo_override.py index c2b9f37..2e0e7d1 100644 --- a/tests/test_repo_override.py +++ b/tests/test_repo_override.py @@ -105,9 +105,9 @@ def test_every_nonexistent_override_is_named_at_once(self) -> None: self.assertNotIn("CONFORMANCE_REPO_SENDSPIN_CLI", message) def test_a_build_step_does_not_report_it_as_a_failed_build(self) -> None: - os.environ["CONFORMANCE_REPO_SENDSPIN_JVM"] = str(self.root / "missing" / "jvm") + os.environ["CONFORMANCE_REPO_SENDSPIN_DOTNET"] = str(self.root / "missing" / "dotnet") with self.assertRaises(RepoOverrideError): - build._gradle_build_result() + build._dotnet_build_result() def test_the_cli_stops_before_building_or_running(self) -> None: os.environ["CONFORMANCE_REPO_SPEC"] = str(self.missing)