test(nests): T16 Phase 2 — I4 stereo forward + reverse green

Lands the test side of the I4 stereo cross-stack scenario on
top of the I4 Phase 1 production code merged from main
(commit 23b8bfd34, AudioBroadcastConfig + per-stream channel
count + stereo catalog factory).

- **SineWaveAudioCapture** extended with `channelCount` +
  `freqHzPerChannel` for L/R asymmetric tones. Mono behavior
  unchanged when callers pass nothing.
- **PcmAssertions.assertFftPeakPerChannel** deinterleaves
  L/R/L/R/... PCM and asserts each channel's spectral peak
  independently. A regression that downmixes to mono or
  swaps channels trips this.
- **hang-publish** (Rust): added `--freq-hz-l` / `--freq-hz-r`
  for per-channel sine generation. `--freq-hz` remains the
  default for any channel without an override.
- **HangInteropTest.amethyst_speaker_to_hang_listener_stereo_440_660**:
  Kotlin speaker broadcasts L=440 / R=660 stereo Opus →
  hang-listen → assert per-channel FFT peaks.
- **HangInteropTest.rust_hang_publish_stereo_to_kotlin_listener_440_660**:
  hang-publish broadcasts stereo → Amethyst `connectNestsListener`
  + `JvmOpusDecoder(channelCount=2)` decodes interleaved
  stereo PCM → assert per-channel FFT peaks.

`runSpeakerToHangListen` gained `channelCount` +
`freqHzPerChannel` parameters; the existing mono scenarios
keep their behavior unchanged.

Both stereo tests pass green on the first try after the
production code change. The reverse test exercises
`connectNestsListener`'s subscribe path end-to-end through
real stereo Opus — the catalog-discovered channel count
plumbs through correctly to the JVM-side decoder.

Picked up post-merge:
  - `AudioFormat.CHANNELS` → `AudioFormat.DEFAULT_CHANNELS`
    rename in JvmOpusEncoder/Decoder.

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
This commit is contained in:
Claude
2026-05-06 23:20:25 +00:00
parent 374a8f02e3
commit 79a4019438
6 changed files with 301 additions and 28 deletions
@@ -32,7 +32,7 @@ import java.nio.ShortBuffer
*/
class JvmOpusDecoder(
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
private val channelCount: Int = AudioFormat.CHANNELS,
private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
) : OpusDecoder {
private val handle: PointerByReference
@@ -42,7 +42,7 @@ import java.nio.ShortBuffer
*/
class JvmOpusEncoder(
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
private val channelCount: Int = AudioFormat.CHANNELS,
private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
targetBitrate: Int = DEFAULT_BITRATE_BPS,
) : OpusEncoder {
private val handle: PointerByReference
@@ -95,6 +95,41 @@ object PcmAssertions {
}
}
/**
* Stereo / multi-channel variant of [assertFftPeak]. [interleaved]
* is L/R/L/R/... (or N-channel interleaved); [expectedHzPerChannel]
* has one entry per channel and each per-channel slice is asserted
* independently. Used by the I4 stereo scenario where left = 440
* and right = 660 — a regression that mixes channels (or sums
* them into mono) trips the per-channel FFT.
*/
fun assertFftPeakPerChannel(
interleaved: FloatArray,
expectedHzPerChannel: DoubleArray,
halfWindowHz: Double = 5.0,
sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
) {
val channels = expectedHzPerChannel.size
require(interleaved.size % channels == 0) {
"interleaved size ${interleaved.size} not divisible by channels=$channels"
}
val perChannelLen = interleaved.size / channels
for (ch in 0 until channels) {
val slice = FloatArray(perChannelLen)
for (i in 0 until perChannelLen) {
slice[i] = interleaved[i * channels + ch]
}
try {
assertFftPeak(slice, expectedHzPerChannel[ch], halfWindowHz, sampleRate)
} catch (t: Throwable) {
throw IllegalStateException(
"channel $ch (expected ${expectedHzPerChannel[ch]} Hz): ${t.message}",
t,
)
}
}
}
/**
* Zero crossings per second within ±[tolerance] (fractional)
* of [expectedPerSecond]. Catches Opus predictor warble that
@@ -37,13 +37,30 @@ import kotlin.math.sin
* 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.
* Defaults to mono (`channelCount = 1`). Stereo with per-channel
* frequencies — the I4 scenario uses 440 Hz left / 660 Hz right —
* is supported via [freqHzPerChannel]: pass an `IntArray` of size
* [channelCount] holding the desired per-channel frequency. If
* left null, every channel runs at [freqHz].
*
* Output PCM is interleaved L/R/L/R/... for stereo — matches the
* format the Android `MediaCodecOpusEncoder` and our
* [JvmOpusEncoder] expect for stereo input.
*/
class SineWaveAudioCapture(
private val freqHz: Int = 440,
private val channelCount: Int = 1,
private val freqHzPerChannel: IntArray? = null,
private val amplitude: Short = 16_383,
) : AudioCapture {
init {
if (freqHzPerChannel != null) {
require(freqHzPerChannel.size == channelCount) {
"freqHzPerChannel.size (${freqHzPerChannel.size}) must equal channelCount ($channelCount)"
}
}
}
private var sampleIdx: Long = 0L
/** Wallclock target for the next frame (`System.nanoTime` units). */
@@ -55,15 +72,21 @@ class SineWaveAudioCapture(
override suspend fun readFrame(): ShortArray? {
val samples = AudioFormat.FRAME_SIZE_SAMPLES
val out = ShortArray(samples)
val out = ShortArray(samples * channelCount)
val baseIdx = sampleIdx
val angularStep = 2.0 * PI * freqHz / AudioFormat.SAMPLE_RATE_HZ
val twoPi = 2.0 * PI
val sampleRate = AudioFormat.SAMPLE_RATE_HZ.toDouble()
for (i in 0 until samples) {
val v = (amplitude * sin(angularStep * (baseIdx + i))).toInt()
// Clamp defensively — amplitude is well below Short.MAX_VALUE
// by default, but a future bigger amplitude could otherwise
// wrap on the .toShort() truncation.
out[i] = v.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
val t = (baseIdx + i).toDouble()
for (ch in 0 until channelCount) {
val freq = freqHzPerChannel?.get(ch) ?: freqHz
val v = (amplitude * sin(twoPi * freq * t / sampleRate)).toInt()
// Clamp defensively — amplitude is well below Short.MAX_VALUE
// by default, but a future bigger amplitude could otherwise
// wrap on the .toShort() truncation.
out[i * channelCount + ch] =
v.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
}
}
sampleIdx = baseIdx + samples
@@ -20,13 +20,16 @@
*/
package com.vitorpamplona.nestsclient.interop.native
import com.vitorpamplona.nestsclient.AudioBroadcastConfig
import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.audio.AudioFormat
import com.vitorpamplona.nestsclient.audio.JvmOpusDecoder
import com.vitorpamplona.nestsclient.audio.JvmOpusEncoder
import com.vitorpamplona.nestsclient.audio.PcmAssertions
import com.vitorpamplona.nestsclient.audio.SineWaveAudioCapture
import com.vitorpamplona.nestsclient.buildRelayConnectTarget
import com.vitorpamplona.nestsclient.connectNestsListener
import com.vitorpamplona.nestsclient.connectNestsSpeaker
import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
@@ -473,6 +476,173 @@ class HangInteropTest {
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
}
/**
* I4 forward — Amethyst Kotlin speaker broadcasts stereo Opus
* (L=440 Hz, R=660 Hz) to `hang-listen`. Asserts each channel's
* peak frequency independently, so a regression that downmixes
* to mono or swaps channels is caught.
*
* Validates the speaker-side stereo path end-to-end:
* - `AudioBroadcastConfig(channelCount = 2)` plumbs through
* `connectNestsSpeaker` → `MoqLiteNestsSpeaker` → catalog,
* - `MoqLiteHangCatalog.opus48k(name, 2)` emits
* `numberOfChannels: 2` in the published JSON,
* - `JvmOpusEncoder(channelCount = 2)` encodes interleaved
* L/R PCM into stereo Opus packets,
* - hang-listen's catalog reader picks up `channels = 2` and
* constructs an `opus::Decoder` for stereo,
* - the resulting Float32 PCM file is interleaved L/R/L/R/...
* with the per-channel tones intact.
*/
@Test
fun amethyst_speaker_to_hang_listener_stereo_440_660() =
runBlocking {
val out =
runSpeakerToHangListen(
speakerSeconds = 5,
captureFirstFrame = false,
channelCount = 2,
freqHzPerChannel = intArrayOf(440, 660),
)
val pcm = readFloat32Pcm(out.pcmFile)
// Skip first 80 ms (40 ms × 2 channels = 80 ms of
// interleaved silence prefix). Opus look-ahead.
val warmup = AudioFormat.SAMPLE_RATE_HZ / 25 * 2
val analysed = pcm.copyOfRange(warmup, pcm.size)
PcmAssertions.assertFftPeakPerChannel(
analysed,
expectedHzPerChannel = doubleArrayOf(440.0, 660.0),
halfWindowHz = 5.0,
)
}
/**
* I4 reverse — `hang-publish` broadcasts stereo Opus
* (L=440 Hz, R=660 Hz); the Amethyst Kotlin listener
* decodes via `JvmOpusDecoder(channelCount = 2)` and
* asserts the per-channel FFT peaks. Mirror of the I4
* forward scenario for the listener-side stereo path.
*
* Validates:
* - hang-publish's `--freq-hz-l` / `--freq-hz-r` /
* `--channels 2` produce a real stereo Opus stream,
* - the Kotlin listener's `subscribeSpeaker` flow
* delivers stereo Opus packets (after timestamp
* strip),
* - `JvmOpusDecoder(channelCount = 2)` correctly
* interleaves L/R PCM,
* - per-channel FFT peaks are intact end-to-end.
*/
@Test
fun rust_hang_publish_stereo_to_kotlin_listener_440_660() =
runBlocking {
val harness = NativeMoqRelayHarness.shared()
val signer: NostrSigner = NostrSignerInternal(KeyPair())
val pubkey = signer.pubKey
val room =
NestsRoomConfig(
authBaseUrl = "<unused-public-relay>",
endpoint = harness.relayUrl,
hostPubkey = pubkey,
roomId = "rt-${UUID.randomUUID()}",
)
val moqNamespace = room.moqNamespace()
val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val transport =
QuicWebTransportFactory(
parentScope = pumpScope,
certificateValidator = PermissiveCertificateValidator(),
)
// Spawn hang-publish under URL=<moqNamespace>, broadcast
// suffix=<pubkey>. The Kotlin listener subscribes to
// exactly that path via `subscribeSpeaker(pubkey)`.
val publishProc =
ProcessBuilder(
harness.hangPublishBin().toString(),
"--relay-url",
"${harness.relayUrl}/$moqNamespace",
"--broadcast",
pubkey,
"--track-name",
"audio/data",
"--channels",
"2",
"--freq-hz-l",
"440",
"--freq-hz-r",
"660",
"--duration",
"5",
).redirectErrorStream(true)
.also { it.environment()["RUST_LOG"] = "info" }
.start()
// Tiny breathing room so the publisher's ANNOUNCE
// Active is on the relay before we subscribe.
Thread.sleep(300)
try {
val listener =
connectNestsListener(
httpClient = StaticTokenNestsClient,
transport = transport,
scope = pumpScope,
room = room,
signer = signer,
)
val subscription = listener.subscribeSpeaker(pubkey)
val decoder = JvmOpusDecoder(channelCount = 2)
val pcm = mutableListOf<Float>()
try {
// Collect for ~4 s wallclock (publisher runs
// 5 s; allow tail buffer). The flow doesn't
// necessarily yield exactly N items — collect
// by time, not count.
withTimeoutOrNull(4_000L) {
subscription.objects.collect { obj ->
val samples = decoder.decode(obj.payload)
for (s in samples) pcm += s.toFloat() / Short.MAX_VALUE.toFloat()
}
}
} finally {
decoder.release()
listener.close()
}
// Read publisher output BEFORE destroying so the
// stream is still open. Stops at EOF when the
// publisher exits naturally (5 s --duration), or
// returns whatever's been written so far if it's
// still running.
val published =
runCatching {
publishProc.inputStream.bufferedReader().readText()
}.getOrDefault("(stdout unavailable)")
publishProc.destroy()
assertTrue(
pcm.size >= AudioFormat.SAMPLE_RATE_HZ * 2,
"expected ≥ 1 s of stereo PCM (= 2× sample rate floats), " +
"got ${pcm.size} floats. hang-publish stderr:\n$published",
)
val pcmArr = pcm.toFloatArray()
// Skip first 80 ms (40 ms × 2 channels) — Opus look-
// ahead silence.
val warmup = AudioFormat.SAMPLE_RATE_HZ / 25 * 2
val analysed = pcmArr.copyOfRange(warmup, pcmArr.size)
PcmAssertions.assertFftPeakPerChannel(
analysed,
expectedHzPerChannel = doubleArrayOf(440.0, 660.0),
halfWindowHz = 5.0,
)
} finally {
pumpScope.coroutineContext[Job]?.cancel()
publishProc.destroy()
}
}
/**
* Rust↔Rust round-trip: pure-Rust through our harness.
* Validates the cargo workspace + relay config + `moq-lite-03`
@@ -587,6 +757,12 @@ private suspend fun runSpeakerToHangListen(
* the reconnect orchestrator.
*/
hotSwapAfterMs: Long? = null,
/**
* Per-channel sine-wave config for the speaker. Default mono
* 440 Hz; I4 stereo passes `(channels=2, freqHzPerChannel=[440, 660])`.
*/
channelCount: Int = 1,
freqHzPerChannel: IntArray? = null,
): HangListenOutput {
val harness = NativeMoqRelayHarness.shared()
@@ -656,6 +832,18 @@ private suspend fun runSpeakerToHangListen(
certificateValidator = PermissiveCertificateValidator(),
)
val captureFactory: () -> SineWaveAudioCapture = {
SineWaveAudioCapture(
freqHz = 440,
channelCount = channelCount,
freqHzPerChannel = freqHzPerChannel,
)
}
val encoderFactory: () -> JvmOpusEncoder = {
JvmOpusEncoder(channelCount = channelCount)
}
val broadcastConfig = AudioBroadcastConfig(channelCount = channelCount)
lateinit var listenProc: Process
try {
val speaker =
@@ -667,8 +855,9 @@ private suspend fun runSpeakerToHangListen(
room = room,
signer = signer,
speakerPubkeyHex = pubkey,
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
encoderFactory = { JvmOpusEncoder() },
captureFactory = captureFactory,
encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
tokenRefreshAfterMs = hotSwapAfterMs,
connector = {
connectNestsSpeaker(
@@ -678,8 +867,9 @@ private suspend fun runSpeakerToHangListen(
room = room,
signer = signer,
speakerPubkeyHex = pubkey,
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
encoderFactory = { JvmOpusEncoder() },
captureFactory = captureFactory,
encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
framesPerGroup = 5,
)
},
@@ -692,8 +882,9 @@ private suspend fun runSpeakerToHangListen(
room = room,
signer = signer,
speakerPubkeyHex = pubkey,
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
encoderFactory = { JvmOpusEncoder() },
captureFactory = captureFactory,
encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
framesPerGroup = 5,
)
}