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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+155
@@ -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.5–5.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
|
||||
}
|
||||
+11
-14
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user