diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/InteropFrameDriver.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/InteropFrameDriver.kt new file mode 100644 index 000000000..1918be0b0 --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/InteropFrameDriver.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.interop + +import com.vitorpamplona.nestsclient.audio.AudioCapture +import com.vitorpamplona.nestsclient.audio.OpusEncoder +import kotlinx.coroutines.channels.Channel + +/** + * Capture seam driven directly by the test. Each [push] hands one + * single-sample PCM frame into the broadcaster's pump; [stop] closes + * the channel so the pump exits cleanly during teardown. + * + * Tests pair this with [InteropStubEncoder] to round-trip a + * deterministic, byte-for-byte verifiable payload through the relay. + */ +class InteropDriverCapture : AudioCapture { + private val frames = Channel(capacity = Channel.UNLIMITED) + + @Volatile private var started: Boolean = false + + override fun start() { + started = true + } + + override suspend fun readFrame(): ShortArray? { + if (!started) return null + return frames.receiveCatching().getOrNull() + } + + override fun stop() { + frames.close() + } + + /** Convenience for the common `push one PCM byte` shape. */ + fun push(byteValue: Int) { + frames.trySend(shortArrayOf(byteValue.toShort())) + } + + fun push(pcm: ShortArray) { + frames.trySend(pcm) + } +} + +/** + * Encoder that emits `` for each + * frame. Lets tests assert byte-for-byte payload integrity through the + * relay without pulling a real Opus encoder onto the JVM build. + */ +class InteropStubEncoder( + private val prefix: ByteArray, +) : OpusEncoder { + override fun encode(pcm: ShortArray): ByteArray = prefix + byteArrayOf(pcm.first().toByte()) + + override fun release() = Unit +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsMuteInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsMuteInteropTest.kt new file mode 100644 index 000000000..5913cb2e4 --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsMuteInteropTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient.interop + +import com.vitorpamplona.nestsclient.NestsRoomConfig +import com.vitorpamplona.nestsclient.OkHttpNestsClient +import com.vitorpamplona.nestsclient.connectNestsListener +import com.vitorpamplona.nestsclient.connectNestsSpeaker +import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * Pin the mute / unmute mid-broadcast contract end-to-end: + * + * - the broadcaster keeps the capture pump running while muted (so + * unmute is sample-accurate) + * - frames pushed during mute do NOT reach subscribers (`if (muted) + * continue` in [com.vitorpamplona.nestsclient.audio.AudioRoomMoqLiteBroadcaster]) + * - frames pushed before / after the muted window DO reach + * subscribers, with the muted ones missing entirely (no silent + * placeholder frames) + * + * Skipped by default — set `-DnestsInterop=true` to enable. + */ +class NostrNestsMuteInteropTest { + @Test + fun mute_drops_frames_unmute_resumes() = + runBlocking { + NostrNestsHarness.assumeNestsInterop() + val harness = harnessOrNull ?: return@runBlocking + + val speakerSigner = NostrSignerInternal(KeyPair()) + val listenerSigner = NostrSignerInternal(KeyPair()) + val room = + NestsRoomConfig( + authBaseUrl = harness.authBaseUrl, + endpoint = harness.moqEndpoint, + hostPubkey = speakerSigner.pubKey, + roomId = "mute-${System.nanoTime()}", + ) + val supervisor = SupervisorJob() + val pumpScope = CoroutineScope(supervisor + Dispatchers.IO) + val capture = InteropDriverCapture() + + val scope = "mute" + InteropDebug.checkpoint(scope, "room=${room.moqNamespace()} speaker=${speakerSigner.pubKey.take(8)}") + + try { + val speaker = + InteropDebug.stepSuspending("$scope/speaker", "connectNestsSpeaker") { + connectNestsSpeaker( + httpClient = httpClient, + transport = transport, + scope = pumpScope, + room = room, + signer = speakerSigner, + speakerPubkeyHex = speakerSigner.pubKey, + captureFactory = { capture }, + encoderFactory = { InteropStubEncoder("FRAME-".encodeToByteArray()) }, + ) + } + val broadcast = + InteropDebug.stepSuspending("$scope/speaker", "startBroadcasting") { + speaker.startBroadcasting() + } + val listener = + InteropDebug.stepSuspending("$scope/listener", "connectNestsListener") { + connectNestsListener( + httpClient = httpClient, + transport = transport, + scope = pumpScope, + room = room, + signer = listenerSigner, + ) + } + val sub = + InteropDebug.stepSuspending("$scope/listener", "subscribeSpeaker") { + listener.subscribeSpeaker(speakerSigner.pubKey) + } + + // Expect exactly 4 unmuted frames; the 2 muted frames in + // the middle should never reach the wire. + val collected = + async(pumpScope.coroutineContext) { + withTimeoutOrNull(RECEIVE_TIMEOUT_MS) { + sub.objects.take(4).toList() + } + } + delay(SUBSCRIBE_SETTLE_MS) + + InteropDebug.checkpoint(scope, "phase 1: unmuted, push 0,1") + capture.push(0) + delay(FRAME_SPACING_MS) + capture.push(1) + delay(MUTE_BOUNDARY_MS) + + InteropDebug.stepSuspending(scope, "setMuted(true)") { broadcast.setMuted(true) } + InteropDebug.checkpoint(scope, "phase 2: muted, push 50,51 (expect dropped)") + capture.push(50) + delay(FRAME_SPACING_MS) + capture.push(51) + delay(MUTE_BOUNDARY_MS) + + InteropDebug.stepSuspending(scope, "setMuted(false)") { broadcast.setMuted(false) } + InteropDebug.checkpoint(scope, "phase 3: unmuted, push 2,3") + capture.push(2) + delay(FRAME_SPACING_MS) + capture.push(3) + + val frames = collected.await() + if (frames == null) { + fail("[$scope] timed out waiting for 4 unmuted frames within ${RECEIVE_TIMEOUT_MS}ms") + } + assertEquals(4, frames.size, "[$scope] expected exactly 4 unmuted frames") + val expected = listOf(0, 1, 2, 3) + frames.forEachIndexed { idx, obj -> + assertContentEquals( + "FRAME-".encodeToByteArray() + byteArrayOf(expected[idx]), + obj.payload, + "[$scope] frame $idx — muted frames must not appear in the stream", + ) + } + + runCatching { sub.unsubscribe() } + runCatching { listener.close() } + runCatching { broadcast.close() } + runCatching { speaker.close() } + } finally { + capture.stop() + supervisor.cancelAndJoin() + } + Unit + } + + companion object { + private const val SUBSCRIBE_SETTLE_MS = 500L + private const val FRAME_SPACING_MS = 25L + private const val MUTE_BOUNDARY_MS = 200L + private const val RECEIVE_TIMEOUT_MS = 10_000L + + private val httpClient = OkHttpNestsClient() + private val transport = + QuicWebTransportFactory(certificateValidator = PermissiveCertificateValidator()) + private var harnessOrNull: NostrNestsHarness? = null + + @BeforeClass + @JvmStatic + fun setUpHarness() { + if (NostrNestsHarness.isEnabled()) { + harnessOrNull = NostrNestsHarness.shared() + } + } + + @AfterClass + @JvmStatic + fun tearDownHarness() { + harnessOrNull = null + } + } +}