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:
@@ -216,7 +216,21 @@ async fn listen(
|
||||
tracing::info!("track ended");
|
||||
break;
|
||||
}
|
||||
Ok(Err(e)) => return Err(anyhow::Error::new(e).context("read audio frame")),
|
||||
Ok(Err(e)) => {
|
||||
// A "cancelled" tail-error after we've already
|
||||
// collected frames is just the publisher closing
|
||||
// its side of the broadcast — treat it as a
|
||||
// normal end-of-stream rather than failing the
|
||||
// whole run. Test scripts assert against the PCM
|
||||
// file size + content, not the exit code's
|
||||
// distinction between graceful-end and
|
||||
// publisher-cancel.
|
||||
if frame_count > 0 {
|
||||
tracing::info!(error = %e, "track cancelled after {frame_count} frames; treating as EOF");
|
||||
break;
|
||||
}
|
||||
return Err(anyhow::Error::new(e).context("read audio frame"));
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::info!("duration elapsed");
|
||||
break;
|
||||
|
||||
@@ -21,8 +21,10 @@ const FRAME_SIZE_SAMPLES: usize = 960;
|
||||
const FRAME_DURATION_US: u64 = 20_000;
|
||||
/// 5 frames per group → 100 ms group cadence, matching nests speaker.
|
||||
const FRAMES_PER_GROUP: usize = 5;
|
||||
/// Audio rendition track name in the catalog.
|
||||
const TRACK_NAME: &str = "audio";
|
||||
/// Default audio rendition track name in the catalog. Amethyst's
|
||||
/// listener subscribes to `audio/data` per `MoqLiteNestsListener.AUDIO_TRACK`,
|
||||
/// so that's what we ship by default. Override via `--track-name`.
|
||||
const DEFAULT_TRACK_NAME: &str = "audio/data";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
@@ -56,6 +58,12 @@ struct Args {
|
||||
/// a Phase-2 follow-up.
|
||||
#[arg(long, default_value_t = 1)]
|
||||
channels: u32,
|
||||
|
||||
/// Audio rendition track name. Default `audio/data` matches
|
||||
/// Amethyst's `MoqLiteNestsListener.AUDIO_TRACK`. Override for
|
||||
/// custom interop scenarios.
|
||||
#[arg(long, default_value_t = DEFAULT_TRACK_NAME.to_string())]
|
||||
track_name: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -81,7 +89,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
async fn run(args: Args) -> anyhow::Result<()> {
|
||||
let url = build_url(&args.relay_url, &args.broadcast, args.jwt.as_deref())?;
|
||||
let url = build_url(&args.relay_url, args.jwt.as_deref())?;
|
||||
|
||||
let cfg = moq_native::ClientConfig::parse_from([
|
||||
"hang-publish",
|
||||
@@ -131,7 +139,7 @@ async fn publish(origin: &moq_lite::OriginProducer, args: &Args) -> anyhow::Resu
|
||||
|
||||
let mut renditions = std::collections::BTreeMap::new();
|
||||
renditions.insert(
|
||||
TRACK_NAME.to_string(),
|
||||
args.track_name.clone(),
|
||||
AudioConfig {
|
||||
codec: AudioCodec::Opus,
|
||||
sample_rate: SAMPLE_RATE_HZ,
|
||||
@@ -163,7 +171,7 @@ async fn publish(origin: &moq_lite::OriginProducer, args: &Args) -> anyhow::Resu
|
||||
// 2. Audio track.
|
||||
let mut audio_track = broadcast
|
||||
.create_track(moq_lite::Track {
|
||||
name: TRACK_NAME.to_string(),
|
||||
name: args.track_name.clone(),
|
||||
priority: 1,
|
||||
})
|
||||
.context("create audio track")?;
|
||||
@@ -260,12 +268,20 @@ async fn publish(origin: &moq_lite::OriginProducer, args: &Args) -> anyhow::Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_url(relay_url: &str, broadcast: &str, jwt: Option<&str>) -> anyhow::Result<url::Url> {
|
||||
/// Build the WebTransport URL the publisher connects to.
|
||||
///
|
||||
/// `relay_url` is taken as the *full* URL the publisher connects to
|
||||
/// (scheme + authority + optional path). `broadcast` is the relative
|
||||
/// announce-suffix passed to `Origin::create_broadcast`, NOT appended
|
||||
/// to the URL. Callers that want the publisher's URL path to also be
|
||||
/// `broadcast` should pass `--relay-url=<host>/<broadcast>` and
|
||||
/// `--broadcast=<broadcast>` (the simple Rust↔Rust shape).
|
||||
fn build_url(relay_url: &str, jwt: Option<&str>) -> anyhow::Result<url::Url> {
|
||||
let trimmed = relay_url.trim_end_matches('/');
|
||||
let raw = if let Some(jwt) = jwt {
|
||||
format!("{trimmed}/{broadcast}?jwt={jwt}")
|
||||
format!("{trimmed}?jwt={jwt}")
|
||||
} else {
|
||||
format!("{trimmed}/{broadcast}")
|
||||
trimmed.to_string()
|
||||
};
|
||||
url::Url::parse(&raw).with_context(|| format!("malformed relay/broadcast url: {raw}"))
|
||||
url::Url::parse(&raw).with_context(|| format!("malformed relay url: {raw}"))
|
||||
}
|
||||
|
||||
@@ -74,6 +74,17 @@ kotlin {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,80 @@
|
||||
# Plan: cross-stack interop test (T16) — Phase 1 results
|
||||
# Plan: cross-stack interop test (T16) — Phase 1 + Phase 2 results
|
||||
|
||||
**Status:** Phase 1 landed (scaffolding-only). Phase 2 + 3 + 4 + 5 deferred.
|
||||
**Status:** Phase 1 + most of Phase 2 landed. Phases 3–5 still deferred.
|
||||
|
||||
## Phase 2 update
|
||||
|
||||
Added on top of the Phase 1 scaffolding:
|
||||
|
||||
- **`hang-listen` real body** — connects to a `moq-lite-03` relay,
|
||||
reads the hang catalog, picks the first Opus / Container::Legacy
|
||||
audio rendition, decodes each Opus packet via the `opus = "0.3"`
|
||||
crate, and writes Float32 little-endian PCM to `--output-pcm`.
|
||||
- **`hang-publish` real body** — claims a broadcast, publishes a hang
|
||||
catalog with one Opus rendition (track name configurable via
|
||||
`--track-name`, default `audio/data` to match Amethyst's
|
||||
`MoqLiteNestsListener.AUDIO_TRACK`), encodes a sine wave with
|
||||
libopus, and pumps Opus frames in 5-frame groups for `--duration`
|
||||
seconds. Uses `audiopus`-equivalent `opus = "0.3"`.
|
||||
- Both binaries explicitly install the rustls aws-lc-rs crypto
|
||||
provider (rustls 0.23 no longer auto-installs) and use
|
||||
`--client-version moq-lite-03` + `--tls-disable-verify=true`
|
||||
to interop with the harness's self-signed `--tls-generate localhost`
|
||||
relay.
|
||||
- **JVM Opus encoder/decoder** via `club.minnced:opus-java:1.1.1`
|
||||
(JNA bindings + bundled libopus.so / libopus.dylib / opus.dll
|
||||
natives). Lives in `nestsClient/src/jvmTest/.../audio/JvmOpusEncoder.kt`
|
||||
+ `JvmOpusDecoder.kt`. Verified by
|
||||
`JvmOpusRoundTripTest.sine_440_round_trips_through_libopus` —
|
||||
encode → decode preserves the FFT peak at 440 Hz and the
|
||||
zero-crossing rate at 880/sec within 5%.
|
||||
- **Real-time pacing** in `SineWaveAudioCapture` — `readFrame`
|
||||
blocks until the next 20-ms boundary, mirroring how a microphone
|
||||
source paces. Without this the broadcaster's read loop would
|
||||
flood the relay with millions of frames/sec.
|
||||
- **`HangInteropTest.rust_hang_publish_to_rust_hang_listener_round_trip_440`**
|
||||
— Rust↔Rust round-trip through the harness. Spawns `hang-publish`
|
||||
+ `hang-listen` as subprocesses, asserts the decoded PCM has FFT
|
||||
peak at 440 Hz, ZCR at 880/sec, and 5 s of samples (±20% slack
|
||||
for Opus look-ahead + relay buffering). Verified green on Linux
|
||||
x86_64.
|
||||
|
||||
## Known gap — Amethyst speaker → hang-listen (I1 forward direction)
|
||||
|
||||
Wired in `HangInteropTest` initially as
|
||||
`amethyst_speaker_to_hang_listener_static_tone_440` but it doesn't
|
||||
pass yet. Symptom: the hang `Container::Legacy` decoder receives
|
||||
each `moq-lite Group { subscribe, sequence }` control message but
|
||||
never receives the per-frame `varint(timestamp_us) + opus` payload
|
||||
that should follow on the same uni stream. Both sides agree on
|
||||
`moq-lite-03`, the audio rendition catalog parses correctly, the
|
||||
audio SUBSCRIBE registers on the speaker's audio publisher
|
||||
(`inboundSubs.size=1`), and the broadcaster's send loop reports
|
||||
50 frames/sec going out — yet hang-listen sees no
|
||||
`varint(size) + bytes` after each Group header.
|
||||
|
||||
The race fix in the test (`speaker.startBroadcasting()` before
|
||||
spawning `hang-listen`) is needed to keep the catalog publisher's
|
||||
`setOnNewSubscriber` hook installed in time, but doesn't unblock
|
||||
the audio path. The catalog uni stream's frame data DOES make it
|
||||
through — only the audio uni stream's frames are lost. The bug is
|
||||
likely in `:nestsClient`'s audio uni-stream framing (in
|
||||
`MoqLiteSession.openGroupStream` / `PublisherStateImpl.send`) and
|
||||
needs a wire-byte capture against the existing Kotlin↔Kotlin
|
||||
listener path to confirm the issue is symmetric (i.e. only Rust
|
||||
fails to read) or producer-side (Kotlin fails to write the frame
|
||||
size prefix the way the spec calls for). The smoke-test version
|
||||
`HangInteropTest.rust_hang_publish_to_rust_hang_listener_round_trip_440`
|
||||
proves the harness + cargo workspace + JVM Opus all work; the
|
||||
Kotlin-speaker path is gated behind this open issue and tracked
|
||||
in this doc.
|
||||
|
||||
When picking up: replace the test body with the speaker-→-listener
|
||||
shape from the plan's "Patterns" section (already prototyped in
|
||||
the deleted `amethyst_speaker_to_hang_listener_static_tone_440`),
|
||||
and capture the first audio uni stream's bytes via a custom
|
||||
`WebTransportFactory` that sniffs writes — then compare against
|
||||
what the Rust subscriber's `run_group` parser expects.
|
||||
|
||||
**Origin:** companion to `nestsClient/plans/2026-05-06-cross-stack-interop-test.md`.
|
||||
This file records what actually shipped in Phase 1, the deviations from
|
||||
|
||||
@@ -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