cbf631ac77
Lands the test-side audio codec + the first end-to-end interop scenario through the harness: - JvmOpusEncoder / JvmOpusDecoder via club.minnced:opus-java 1.1.1 (JNA bindings + bundled libopus.so / .dylib / .dll natives). Verified by JvmOpusRoundTripTest — sine 440 Hz survives encode → decode with FFT peak preserved + ZCR within 5%. - SineWaveAudioCapture now paces to real time (20 ms / frame) rather than running open-loop. Mirrors how a real microphone source blocks on hardware; without it the broadcaster floods the relay at compute speed. - HangInteropTest.rust_hang_publish_to_rust_hang_listener_round_trip_440 drives hang-publish + hang-listen as subprocesses through the harness's moq-relay and asserts FFT peak / ZCR / sample-count on the decoded PCM. Verified green on Linux x86_64. - hang-publish gains --track-name (default "audio/data" matching Amethyst's MoqLiteNestsListener.AUDIO_TRACK) and decouples --relay-url from --broadcast so the URL path can be the namespace and the broadcast can be a relative announce suffix. - hang-listen's tail "cancelled" error is treated as EOF after any frames have been collected, so a clean publisher shutdown no longer surfaces as exit=1. The forward-direction I1 scenario (Amethyst Kotlin speaker → hang listener) is still gated by an open Amethyst-side wire issue: the audio uni stream delivers Group control headers but no frame payloads. Documented in nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md with concrete pickup steps for a follow-up session. https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
224 lines
8.7 KiB
Kotlin
224 lines
8.7 KiB
Kotlin
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
|
|
plugins {
|
|
alias(libs.plugins.kotlinMultiplatform)
|
|
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
|
alias(libs.plugins.serialization)
|
|
}
|
|
|
|
kotlin {
|
|
jvm {
|
|
compilerOptions {
|
|
jvmTarget.set(JvmTarget.JVM_21)
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "com.vitorpamplona.nestsclient"
|
|
compileSdk =
|
|
libs.versions.android.compileSdk
|
|
.get()
|
|
.toInt()
|
|
minSdk =
|
|
libs.versions.android.minSdk
|
|
.get()
|
|
.toInt()
|
|
|
|
compilerOptions {
|
|
jvmTarget.set(JvmTarget.JVM_21)
|
|
}
|
|
|
|
withHostTest {}
|
|
}
|
|
|
|
sourceSets {
|
|
commonMain {
|
|
dependencies {
|
|
implementation(libs.kotlin.stdlib)
|
|
implementation(libs.kotlinx.coroutines.core)
|
|
implementation(libs.kotlinx.serialization.json)
|
|
api(project(":quartz"))
|
|
implementation(project(":quic"))
|
|
}
|
|
}
|
|
|
|
commonTest {
|
|
dependencies {
|
|
implementation(libs.kotlin.test)
|
|
implementation(libs.kotlinx.coroutines.test)
|
|
}
|
|
}
|
|
|
|
val jvmAndroid =
|
|
create("jvmAndroid") {
|
|
dependsOn(commonMain.get())
|
|
dependencies {
|
|
implementation(libs.okhttp)
|
|
implementation(libs.okhttpCoroutines)
|
|
}
|
|
}
|
|
|
|
jvmMain {
|
|
dependsOn(jvmAndroid)
|
|
}
|
|
|
|
androidMain {
|
|
dependsOn(jvmAndroid)
|
|
// Kwik QUIC + Flupke HTTP/3 dependencies are NOT yet declared.
|
|
// See KwikWebTransportFactory.kt for the integration plan and
|
|
// validated Maven coordinates / minimum versions before adding.
|
|
}
|
|
|
|
jvmTest {
|
|
dependencies {
|
|
implementation(libs.kotlin.test)
|
|
implementation(libs.kotlinx.coroutines.test)
|
|
implementation(libs.secp256k1.kmp.jni.jvm)
|
|
// JNA bindings + bundled libopus.so used by the cross-stack
|
|
// interop tests (T16). The Android targets keep their
|
|
// existing `MediaCodecOpusEncoder/Decoder`; only JVM
|
|
// tests need a host-side codec, and `club.minnced:opus-java`
|
|
// ships natives for linux-x86-64 / aarch64 / darwin / win32.
|
|
// No Android dependency is added. opus-java-api declares
|
|
// JNA as runtime-scope; Kotlin needs it at compile time to
|
|
// resolve the `tomp2p.opuswrapper.Opus extends com.sun.jna.Library`
|
|
// supertype, so pull it explicitly.
|
|
implementation("club.minnced:opus-java:1.1.1")
|
|
implementation("net.java.dev.jna:jna:5.14.0")
|
|
}
|
|
}
|
|
|
|
getByName("androidHostTest") {
|
|
dependencies {
|
|
implementation(libs.kotlin.test)
|
|
implementation(libs.kotlinx.coroutines.test)
|
|
implementation(libs.secp256k1.kmp.jni.jvm)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forward the nostrnests interop opt-in property from the Gradle JVM
|
|
// to test workers. Without this, `-DnestsInterop=true` on the Gradle
|
|
// command line never reaches `NostrNestsHarness.isEnabled()` (which
|
|
// reads it via `System.getProperty`), so every interop test silently
|
|
// skips. See `nestsClient/src/jvmTest/.../interop/NostrNestsHarness.kt`.
|
|
tasks.withType<Test>().configureEach {
|
|
System.getProperty("nestsInterop")?.let { systemProperty("nestsInterop", it) }
|
|
System.getProperty("nestsInteropRev")?.let { systemProperty("nestsInteropRev", it) }
|
|
System.getProperty("nestsInteropMoqRev")?.let { systemProperty("nestsInteropMoqRev", it) }
|
|
System.getProperty("nestsInteropExternal")?.let { systemProperty("nestsInteropExternal", it) }
|
|
System.getProperty("nestsInteropDebug")?.let { systemProperty("nestsInteropDebug", it) }
|
|
// Opt-in for tests that hit the real nostrnests.com infrastructure
|
|
// (see NostrnestsProdAudioTransmissionTest). Forwarded the same way
|
|
// the harness flags are.
|
|
System.getProperty("nestsProd")?.let { systemProperty("nestsProd", it) }
|
|
System.getProperty("nestsProdEndpoint")?.let { systemProperty("nestsProdEndpoint", it) }
|
|
System.getProperty("nestsProdAuth")?.let { systemProperty("nestsProdAuth", it) }
|
|
// Cross-stack interop (Hang/Rust) opt-in. Forwarded the same way as
|
|
// -DnestsInterop. See nestsClient/plans/2026-05-06-cross-stack-interop-test.md.
|
|
System.getProperty("nestsHangInterop")?.let { systemProperty("nestsHangInterop", it) }
|
|
}
|
|
|
|
// ---- Cross-stack interop: Rust sidecar build + binary path forwarding -------
|
|
//
|
|
// Phase 1 of the interop plan ships the workspace at `cli/hang-interop/`
|
|
// with three stub binaries (hang-listen, hang-publish, udp-loss-shim).
|
|
// `interopBuildHangSidecars` runs `cargo build --release` against it and
|
|
// resolves the upstream `moq-relay` + `moq-token` binaries via
|
|
// `cargo install`, caching everything under
|
|
// `~/.cache/amethyst-nests-interop/hang-interop-cargo/` so reruns are
|
|
// fast. Binary paths are forwarded to test workers as system properties.
|
|
//
|
|
// Opt-in only: Phase 1 just verifies the harness can boot a relay; the
|
|
// actual interop scenarios land in Phase 2 once `hang-listen` /
|
|
// `hang-publish` have real subscribe/publish loops. See
|
|
// `nestsClient/plans/2026-05-06-cross-stack-interop-test.md` for the
|
|
// full plan and the pinned upstream versions in `cli/hang-interop/REV`.
|
|
|
|
val hangInteropDir = rootProject.layout.projectDirectory.dir("cli/hang-interop")
|
|
val hangInteropCacheDir =
|
|
layout.projectDirectory
|
|
.dir(System.getProperty("user.home") ?: "/tmp")
|
|
.dir(".cache/amethyst-nests-interop/hang-interop-cargo")
|
|
|
|
// Versions are duplicated from cli/hang-interop/REV so Gradle has them
|
|
// at configuration time; bumping requires touching both files.
|
|
val moqRelayVersion = "0.10.25"
|
|
val moqTokenCliVersion = "0.5.23"
|
|
|
|
val interopInstallMoqRelay by tasks.registering(Exec::class) {
|
|
description = "cargo install moq-relay $moqRelayVersion (interop)"
|
|
group = "interop"
|
|
commandLine(
|
|
"cargo", "install",
|
|
"moq-relay",
|
|
"--version", moqRelayVersion,
|
|
"--root", hangInteropCacheDir.asFile.absolutePath,
|
|
"--locked",
|
|
)
|
|
val installed =
|
|
hangInteropCacheDir.dir("bin").file(
|
|
if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-relay.exe" else "moq-relay",
|
|
)
|
|
outputs.file(installed)
|
|
outputs.cacheIf { true }
|
|
onlyIf { !installed.asFile.exists() }
|
|
doFirst { hangInteropCacheDir.asFile.mkdirs() }
|
|
}
|
|
|
|
val interopInstallMoqTokenCli by tasks.registering(Exec::class) {
|
|
description = "cargo install moq-token-cli $moqTokenCliVersion (interop)"
|
|
group = "interop"
|
|
commandLine(
|
|
"cargo", "install",
|
|
"moq-token-cli",
|
|
"--version", moqTokenCliVersion,
|
|
"--root", hangInteropCacheDir.asFile.absolutePath,
|
|
"--locked",
|
|
)
|
|
val installed =
|
|
hangInteropCacheDir.dir("bin").file(
|
|
if (org.gradle.internal.os.OperatingSystem.current().isWindows) "moq-token-cli.exe" else "moq-token-cli",
|
|
)
|
|
outputs.file(installed)
|
|
outputs.cacheIf { true }
|
|
onlyIf { !installed.asFile.exists() }
|
|
doFirst { hangInteropCacheDir.asFile.mkdirs() }
|
|
}
|
|
|
|
val interopBuildSidecars by tasks.registering(Exec::class) {
|
|
description = "cargo build --release for cli/hang-interop sidecars"
|
|
group = "interop"
|
|
workingDir = hangInteropDir.asFile
|
|
commandLine("cargo", "build", "--release")
|
|
// Track only manifests + sources; the `target/` subtree is the
|
|
// output, including it as an input would mark the task always
|
|
// out-of-date.
|
|
val sidecarSources =
|
|
fileTree(hangInteropDir.asFile) {
|
|
include("Cargo.toml", "Cargo.lock")
|
|
include("hang-listen/**", "hang-publish/**", "udp-loss-shim/**")
|
|
exclude("**/target/**")
|
|
}
|
|
inputs.files(sidecarSources)
|
|
outputs.dir(hangInteropDir.dir("target/release"))
|
|
}
|
|
|
|
val interopBuildHangSidecars by tasks.registering {
|
|
description = "Build all hang-interop binaries (sidecars + moq-relay + moq-token)."
|
|
group = "interop"
|
|
dependsOn(interopBuildSidecars, interopInstallMoqRelay, interopInstallMoqTokenCli)
|
|
}
|
|
|
|
tasks.withType<Test>().configureEach {
|
|
val isHangInterop = System.getProperty("nestsHangInterop") == "true"
|
|
if (isHangInterop) {
|
|
dependsOn(interopBuildHangSidecars)
|
|
}
|
|
val sidecarRelease = hangInteropDir.dir("target/release").asFile
|
|
val cargoBin = hangInteropCacheDir.dir("bin").asFile
|
|
systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.absolutePath)
|
|
systemProperty("nestsHangInteropCargoBinDir", cargoBin.absolutePath)
|
|
}
|