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:
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+32
-9
@@ -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
|
||||
|
||||
|
||||
+197
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user