feat(nests): T16 Phase 2 — JVM Opus + Rust↔Rust E2E interop test

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
This commit is contained in:
Claude
2026-05-06 21:32:36 +00:00
parent 33a9e8f053
commit cbf631ac77
10 changed files with 569 additions and 33 deletions
@@ -0,0 +1,73 @@
/*
* 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.audio
import com.sun.jna.ptr.PointerByReference
import tomp2p.opuswrapper.Opus
import java.nio.IntBuffer
import java.nio.ShortBuffer
/**
* [OpusDecoder] backed by libopus via JNA. Mirror of
* [MediaCodecOpusDecoder] for JVM tests — same per-stream
* statefulness rules apply.
*/
class JvmOpusDecoder(
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
private val channelCount: Int = AudioFormat.CHANNELS,
) : OpusDecoder {
private val handle: PointerByReference
/** 120 ms at 48 kHz — Opus's worst-case decode frame size. */
private val out = ShortBuffer.allocate(sampleRate / 1000 * 120 * channelCount)
init {
JvmOpusEncoder.ensureNativesLoaded()
val err = IntBuffer.allocate(1)
handle = Opus.INSTANCE.opus_decoder_create(sampleRate, channelCount, err)
check(err.get(0) == 0) { "opus_decoder_create failed: error ${err.get(0)}" }
}
override fun decode(opusPacket: ByteArray): ShortArray {
out.clear()
val n =
Opus.INSTANCE.opus_decode(
handle,
opusPacket,
opusPacket.size,
out,
out.capacity() / channelCount,
// 0 = no FEC — match what MediaCodecOpusDecoder does on
// a normal-arrival packet.
0,
)
check(n >= 0) { "opus_decode returned $n (negative is an error)" }
val interleaved = n * channelCount
val pcm = ShortArray(interleaved)
out.position(0)
out.get(pcm, 0, interleaved)
return pcm
}
override fun release() {
Opus.INSTANCE.opus_decoder_destroy(handle)
}
}
@@ -0,0 +1,110 @@
/*
* 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.audio
import club.minnced.opus.util.OpusLibrary
import com.sun.jna.ptr.PointerByReference
import tomp2p.opuswrapper.Opus
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.IntBuffer
import java.nio.ShortBuffer
/**
* [OpusEncoder] backed by libopus via JNA (`club.minnced:opus-java`).
* Test-only — JVM tests need a host-side codec and `MediaCodec` is
* Android-only. The natives are bundled in the jar (linux-x86-64,
* linux-aarch64, darwin, win32, win32-x86-64), unpacked from
* [OpusLibrary.loadFromJar] on first use.
*
* Mirror of [MediaCodecOpusEncoder]'s contract: 48 kHz mono /
* stereo PCM 16-bit input → Opus packet bytes. Stateful
* (libopus carries forward predictor state); use one instance per
* outgoing track.
*/
class JvmOpusEncoder(
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
private val channelCount: Int = AudioFormat.CHANNELS,
targetBitrate: Int = DEFAULT_BITRATE_BPS,
) : OpusEncoder {
private val handle: PointerByReference
/** Sized for libopus's worst-case output for one 20 ms frame. */
private val out = ByteBuffer.allocateDirect(MAX_OPUS_PACKET_BYTES).order(ByteOrder.nativeOrder())
init {
ensureNativesLoaded()
val err = IntBuffer.allocate(1)
handle =
Opus.INSTANCE.opus_encoder_create(
sampleRate,
channelCount,
Opus.OPUS_APPLICATION_AUDIO,
err,
)
check(err.get(0) == 0) { "opus_encoder_create failed: error ${err.get(0)}" }
Opus.INSTANCE.opus_encoder_ctl(handle, Opus.OPUS_SET_BITRATE_REQUEST, targetBitrate)
}
override fun encode(pcm: ShortArray): ByteArray {
// libopus wants exactly one frame at a time; for 48 kHz mono
// that's `FRAME_SIZE_SAMPLES` samples. The interface contract
// doesn't enforce length, so we pass the caller's array as-is
// and let libopus's frame-size validator reject mis-sizes.
val frameSize = pcm.size / channelCount
val pcmBuffer = ShortBuffer.wrap(pcm)
out.clear()
val n = Opus.INSTANCE.opus_encode(handle, pcmBuffer, frameSize, out, out.capacity())
check(n > 0) { "opus_encode returned $n (negative is an error)" }
// JNA writes to the native buffer but doesn't advance the JVM
// position; reset to 0 and absolute-read `n` bytes out.
val packet = ByteArray(n)
out.position(0)
out.get(packet, 0, n)
return packet
}
override fun release() {
Opus.INSTANCE.opus_encoder_destroy(handle)
}
companion object {
const val DEFAULT_BITRATE_BPS: Int = 32_000
/** libopus's worst-case packet size; spec says ≤ 1275 per channel × 3 frames. */
private const val MAX_OPUS_PACKET_BYTES: Int = 4_000
@Volatile private var nativesLoaded: Boolean = false
private val loadLock = Any()
internal fun ensureNativesLoaded() {
if (nativesLoaded) return
synchronized(loadLock) {
if (nativesLoaded) return
check(OpusLibrary.isSupportedPlatform()) {
"club.minnced:opus-java natives not available for this platform"
}
check(OpusLibrary.loadFromJar()) { "OpusLibrary.loadFromJar() returned false" }
nativesLoaded = true
}
}
}
}
@@ -0,0 +1,70 @@
/*
* 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.audio
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
/**
* Sanity check that [JvmOpusEncoder] + [JvmOpusDecoder] round-trip a
* sine wave: the test pumps 1 s of 440 Hz from
* [SineWaveAudioCapture] through encode → decode and asserts the
* decoded float-PCM still has its peak at 440 Hz.
*
* Catches: native-load failures (missing platform support), wrong
* sample-rate / channel-count plumbing, encoder/decoder state-
* leak bugs that distort the waveform.
*/
class JvmOpusRoundTripTest {
@Test
fun sine_440_round_trips_through_libopus() {
val capture = SineWaveAudioCapture(freqHz = 440)
val encoder = JvmOpusEncoder()
val decoder = JvmOpusDecoder()
try {
val decoded = mutableListOf<Float>()
runBlocking {
// 50 frames × 20 ms = 1.0 s at 48 kHz.
repeat(50) {
val pcm = capture.readFrame() ?: return@runBlocking
val packet = encoder.encode(pcm)
val out = decoder.decode(packet)
for (s in out) decoded.add(s.toFloat() / Short.MAX_VALUE.toFloat())
}
}
val floats = decoded.toFloatArray()
// Opus has ~6.5 ms look-ahead → first frame is silence.
// Drop the first 20 ms (one frame) to keep the FFT clean.
val skip = AudioFormat.FRAME_SIZE_SAMPLES
val analysed = floats.copyOfRange(skip, floats.size)
PcmAssertions.assertSampleCount(analysed, expectedDurationSec = 0.98, tolerance = 0.05)
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
PcmAssertions.assertZeroCrossingRate(
analysed,
expectedPerSecond = 880.0,
tolerance = 0.05,
)
} finally {
encoder.release()
decoder.release()
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.nestsclient.audio
import kotlinx.coroutines.delay
import kotlin.math.PI
import kotlin.math.sin
@@ -28,12 +29,13 @@ import kotlin.math.sin
*
* Generates [AudioFormat.FRAME_SIZE_SAMPLES] samples per call at the
* audio pipeline's native [AudioFormat.SAMPLE_RATE_HZ] (48 kHz). The
* sample counter is frame-perfect and never reads wall-clock — what
* the test sends is exactly what reaches the decoder. The decoded
* peak-frequency assertion in
* [com.vitorpamplona.nestsclient.audio.PcmAssertions.assertFftPeak]
* relies on this determinism; a wall-clock-based source would drift
* and trigger spurious failures on slow CI workers.
* sample counter is frame-perfect and the function paces itself to
* real time — production microphone sources block on hardware until
* a frame's worth of samples are available, so the broadcaster's
* read loop relies on `readFrame` not returning faster than wallclock.
* Without that pacing the encoder + relay would be flooded with
* 50 million frames/sec instead of 50 frames/sec, fill the relay's
* buffers, and surface as "no inboundSubs" frame drops.
*
* Mono only (`channels = 1`) for Phase 1 — the I4 stereo scenario
* (Phase 2) extends this to a per-channel `freqHzL` / `freqHzR` pair.
@@ -44,8 +46,11 @@ class SineWaveAudioCapture(
) : AudioCapture {
private var sampleIdx: Long = 0L
/** Wallclock target for the next frame (`System.nanoTime` units). */
private var nextFrameNanos: Long = 0L
override fun start() {
// No device to allocate.
nextFrameNanos = System.nanoTime() + FRAME_NANOS
}
override suspend fun readFrame(): ShortArray? {
@@ -61,10 +66,21 @@ class SineWaveAudioCapture(
out[i] = v.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
}
sampleIdx = baseIdx + samples
// Pace to real time — block until the next 20-ms boundary.
val now = System.nanoTime()
val sleepNanos = nextFrameNanos - now
if (sleepNanos > 0) delay(sleepNanos / 1_000_000L)
nextFrameNanos += FRAME_NANOS
return out
}
override fun stop() {
// No device to release.
}
private companion object {
/** 20 ms in nanoseconds — the audio pipeline's frame cadence. */
private const val FRAME_NANOS: Long = AudioFormat.FRAME_DURATION_US * 1_000L
}
}
@@ -0,0 +1,155 @@
/*
* 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.native
import com.vitorpamplona.nestsclient.audio.AudioFormat
import com.vitorpamplona.nestsclient.audio.PcmAssertions
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Cross-stack interop scenarios driving the reference `kixelated/moq`
* `hang-publish` and `hang-listen` Rust binaries through
* [NativeMoqRelayHarness] (i.e. through the same `moq-relay`
* subprocess Amethyst tests use).
*
* Phase 2 ships the **Rust↔Rust** scenario — a pure-Rust round-trip
* over our harness. This proves:
* - the cargo workspace at `cli/hang-interop/` builds binaries
* that interop with `moq-relay 0.10.x` over `moq-lite-03`;
* - the harness's relay configuration (`--auth-public ""`, self-
* signed TLS, sandbox-IPv4 client bind) accepts real publishers
* and subscribers;
* - signal-domain assertions over a 5 s 440 Hz tone catch any
* wire-format drift in either binary.
*
* **Phase 2 deferred**: the **Amethyst speaker → hang-listen**
* scenario (the spec's I1) is wired in `:nestsClient` but currently
* fails because the Kotlin speaker's audio uni stream isn't
* delivering frame bytes to the upstream hang `Container::Legacy`
* decoder — the Group control message arrives but no
* `varint(timestamp_us) + opus` payload follows. Tracked in
* `nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md`.
*
* Gated by `-DnestsHangInterop=true`.
*/
class HangInteropTest {
@BeforeTest
fun gate() {
NativeMoqRelayHarness.assumeHangInterop()
}
/**
* Drive the Rust `hang-publish` and `hang-listen` binaries
* through our harness's `moq-relay` subprocess. End-to-end:
* 5 s of 440 Hz mono Opus → 880 zero-crossings/sec, FFT peak
* at 440 Hz, ~5 s of decoded PCM in the temp file.
*/
@Test
fun rust_hang_publish_to_rust_hang_listener_round_trip_440() {
val harness = NativeMoqRelayHarness.shared()
val broadcast = "test/${UUID.randomUUID()}"
val pcmFile = File.createTempFile("hang-listen-pcm", ".bin").also { it.deleteOnExit() }
val publishProc =
ProcessBuilder(
harness.hangPublishBin().toString(),
"--relay-url",
"${harness.relayUrl}/$broadcast",
"--broadcast",
broadcast,
"--track-name",
"audio",
"--duration",
"5",
"--freq-hz",
"440",
).redirectErrorStream(true)
.also { it.environment()["RUST_LOG"] = "info" }
.start()
// Tiny breathing room so the publisher's ANNOUNCE Active
// has propagated to the relay before the listener's
// OriginConsumer.announced() returns.
Thread.sleep(300)
val listenProc =
ProcessBuilder(
harness.hangListenBin().toString(),
"--relay-url",
harness.relayUrl,
"--broadcast",
broadcast,
"--duration",
"6",
"--output-pcm",
pcmFile.absolutePath,
).redirectErrorStream(true)
.also { it.environment()["RUST_LOG"] = "info" }
.start()
val pubExit = publishProc.waitFor(15, TimeUnit.SECONDS)
val listenExit = listenProc.waitFor(15, TimeUnit.SECONDS)
val pubOut = publishProc.inputStream.bufferedReader().readText()
val listenOut = listenProc.inputStream.bufferedReader().readText()
assertTrue(pubExit, "hang-publish did not exit. Output:\n$pubOut")
assertTrue(listenExit, "hang-listen did not exit. Output:\n$listenOut")
assertEquals(0, publishProc.exitValue(), "hang-publish exited non-zero. Output:\n$pubOut")
assertEquals(0, listenProc.exitValue(), "hang-listen exited non-zero. Output:\n$listenOut")
val pcm = readFloat32Pcm(pcmFile)
// hang-publish ran for 5 s @ 50 fps mono Opus. With Opus
// look-ahead + relay buffering + listener's per-group
// catch-up window, expect 4.55.0 s of decoded audio.
PcmAssertions.assertSampleCount(pcm, expectedDurationSec = 5.0, tolerance = 0.20)
// Skip the first 40 ms so the FFT window doesn't include
// Opus's silence-prefilled look-ahead.
val warmupSamples = AudioFormat.SAMPLE_RATE_HZ / 25
val analysed = pcm.copyOfRange(warmupSamples, pcm.size)
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
PcmAssertions.assertZeroCrossingRate(
analysed,
expectedPerSecond = 880.0,
tolerance = 0.05,
)
}
}
/**
* Read a file of native-endian Float32 little-endian PCM into a
* [FloatArray]. The hang-listen binary writes LE Float32, no header.
*/
private fun readFloat32Pcm(file: File): FloatArray {
val bytes = file.readBytes()
require(bytes.size % 4 == 0) {
"PCM file size ${bytes.size} is not a multiple of 4 (Float32)"
}
val n = bytes.size / 4
val out = FloatArray(n)
val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
for (i in 0 until n) out[i] = buf.float
return out
}
@@ -85,28 +85,25 @@ class NativeMoqRelayHarnessSmokeTest {
}
@Test
fun stub_hang_listen_runs_cleanly() {
fun hang_listen_invokes_with_help_flag() {
// Phase 2 fleshed in the real subscribe loop. The cheapest
// smoke check that doesn't need a publisher is `--help` —
// proves the binary is reachable from the test JVM, clap
// parsing succeeds, and the bundled libopus / aws-lc-rs
// natives load on the host platform.
val harness = NativeMoqRelayHarness.shared()
// The stub returns immediately with exit code 0. This proves
// the binary is reachable from the test JVM and clap parsing
// succeeds — i.e. Phase 2 only has to flesh out the body.
val proc =
ProcessBuilder(
harness.hangListenBin().toString(),
"--relay-url",
harness.relayUrl,
"--broadcast",
"test/smoke",
"--duration",
"1",
"--help",
).redirectErrorStream(true).start()
val exited = proc.waitFor(10, TimeUnit.SECONDS)
val output = proc.inputStream.bufferedReader().readText()
assertTrue(exited, "hang-listen stub did not exit within 10 s. Output:\n$output")
assertEquals(0, proc.exitValue(), "hang-listen stub exited non-zero. Output:\n$output")
assertTrue(exited, "hang-listen --help did not exit within 10 s. Output:\n$output")
assertEquals(0, proc.exitValue(), "hang-listen --help exited non-zero. Output:\n$output")
assertTrue(
output.contains("Phase-1 stub"),
"expected hang-listen Phase-1 stub banner in output. Got:\n$output",
output.contains("--relay-url"),
"expected --relay-url in hang-listen --help output. Got:\n$output",
)
}
}