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:
@@ -44,18 +44,30 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
broadcast: String,
|
broadcast: String,
|
||||||
|
|
||||||
/// Sine-wave frequency in Hz (mono only). For stereo, see
|
/// Sine-wave frequency in Hz. Used as the default for every
|
||||||
/// `--freq-hz-right` once that lands.
|
/// channel; override per-channel via `--freq-hz-l` / `--freq-hz-r`.
|
||||||
#[arg(long, default_value_t = 440)]
|
#[arg(long, default_value_t = 440)]
|
||||||
freq_hz: u32,
|
freq_hz: u32,
|
||||||
|
|
||||||
|
/// Per-channel frequency override for the LEFT channel. Falls
|
||||||
|
/// back to `--freq-hz` when unset.
|
||||||
|
#[arg(long)]
|
||||||
|
freq_hz_l: Option<u32>,
|
||||||
|
|
||||||
|
/// Per-channel frequency override for the RIGHT channel.
|
||||||
|
/// Ignored when `--channels 1`. Falls back to `--freq-hz` when
|
||||||
|
/// unset.
|
||||||
|
#[arg(long)]
|
||||||
|
freq_hz_r: Option<u32>,
|
||||||
|
|
||||||
/// Maximum runtime in seconds.
|
/// Maximum runtime in seconds.
|
||||||
#[arg(long, default_value_t = 5)]
|
#[arg(long, default_value_t = 5)]
|
||||||
duration: u64,
|
duration: u64,
|
||||||
|
|
||||||
/// Channel count: 1 (mono) or 2 (stereo). Stereo uses the same
|
/// Channel count: 1 (mono) or 2 (stereo). With `2` and
|
||||||
/// frequency on both channels for now; per-channel frequency is
|
/// `--freq-hz-l` / `--freq-hz-r` set, the L/R channels carry
|
||||||
/// a Phase-2 follow-up.
|
/// independent tones — useful for the I4 stereo cross-stack
|
||||||
|
/// scenario.
|
||||||
#[arg(long, default_value_t = 1)]
|
#[arg(long, default_value_t = 1)]
|
||||||
channels: u32,
|
channels: u32,
|
||||||
|
|
||||||
@@ -188,8 +200,19 @@ async fn publish(origin: &moq_lite::OriginProducer, args: &Args) -> anyhow::Resu
|
|||||||
.context("set opus bitrate")?;
|
.context("set opus bitrate")?;
|
||||||
|
|
||||||
let total_frames = (args.duration * 1_000_000 / FRAME_DURATION_US) as usize;
|
let total_frames = (args.duration * 1_000_000 / FRAME_DURATION_US) as usize;
|
||||||
let phase_step =
|
// Per-channel phase step: each channel may have its own
|
||||||
2.0_f64 * std::f64::consts::PI * (args.freq_hz as f64) / (SAMPLE_RATE_HZ as f64);
|
// frequency (I4 stereo). Defaults to args.freq_hz on every
|
||||||
|
// channel.
|
||||||
|
let mut phase_steps: Vec<f64> = Vec::with_capacity(args.channels as usize);
|
||||||
|
for ch in 0..(args.channels as usize) {
|
||||||
|
let f = match (ch, args.freq_hz_l, args.freq_hz_r) {
|
||||||
|
(0, Some(l), _) => l,
|
||||||
|
(1, _, Some(r)) => r,
|
||||||
|
_ => args.freq_hz,
|
||||||
|
};
|
||||||
|
phase_steps
|
||||||
|
.push(2.0_f64 * std::f64::consts::PI * (f as f64) / (SAMPLE_RATE_HZ as f64));
|
||||||
|
}
|
||||||
let mut sample_idx: u64 = 0;
|
let mut sample_idx: u64 = 0;
|
||||||
// Sized to libopus's worst-case output for one 20 ms frame.
|
// Sized to libopus's worst-case output for one 20 ms frame.
|
||||||
let mut opus_buf = vec![0u8; 4_000];
|
let mut opus_buf = vec![0u8; 4_000];
|
||||||
@@ -201,13 +224,14 @@ async fn publish(origin: &moq_lite::OriginProducer, args: &Args) -> anyhow::Resu
|
|||||||
let mut group: Option<moq_lite::GroupProducer> = None;
|
let mut group: Option<moq_lite::GroupProducer> = None;
|
||||||
|
|
||||||
for frame_no in 0..total_frames {
|
for frame_no in 0..total_frames {
|
||||||
// Generate one PCM frame at the configured frequency.
|
// Generate one PCM frame, possibly with a different sine
|
||||||
|
// tone on each channel.
|
||||||
let mut pcm = vec![0i16; FRAME_SIZE_SAMPLES * args.channels as usize];
|
let mut pcm = vec![0i16; FRAME_SIZE_SAMPLES * args.channels as usize];
|
||||||
for i in 0..FRAME_SIZE_SAMPLES {
|
for i in 0..FRAME_SIZE_SAMPLES {
|
||||||
let t = sample_idx + i as u64;
|
let t = (sample_idx + i as u64) as f64;
|
||||||
let v = ((t as f64) * phase_step).sin();
|
|
||||||
let s = (v * 16_383.0) as i16;
|
|
||||||
for ch in 0..(args.channels as usize) {
|
for ch in 0..(args.channels as usize) {
|
||||||
|
let v = (t * phase_steps[ch]).sin();
|
||||||
|
let s = (v * 16_383.0) as i16;
|
||||||
pcm[i * args.channels as usize + ch] = s;
|
pcm[i * args.channels as usize + ch] = s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ import java.nio.ShortBuffer
|
|||||||
*/
|
*/
|
||||||
class JvmOpusDecoder(
|
class JvmOpusDecoder(
|
||||||
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
|
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
|
||||||
private val channelCount: Int = AudioFormat.CHANNELS,
|
private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
|
||||||
) : OpusDecoder {
|
) : OpusDecoder {
|
||||||
private val handle: PointerByReference
|
private val handle: PointerByReference
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ import java.nio.ShortBuffer
|
|||||||
*/
|
*/
|
||||||
class JvmOpusEncoder(
|
class JvmOpusEncoder(
|
||||||
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
|
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,
|
targetBitrate: Int = DEFAULT_BITRATE_BPS,
|
||||||
) : OpusEncoder {
|
) : OpusEncoder {
|
||||||
private val handle: PointerByReference
|
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)
|
* Zero crossings per second within ±[tolerance] (fractional)
|
||||||
* of [expectedPerSecond]. Catches Opus predictor warble that
|
* 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
|
* 50 million frames/sec instead of 50 frames/sec, fill the relay's
|
||||||
* buffers, and surface as "no inboundSubs" frame drops.
|
* buffers, and surface as "no inboundSubs" frame drops.
|
||||||
*
|
*
|
||||||
* Mono only (`channels = 1`) for Phase 1 — the I4 stereo scenario
|
* Defaults to mono (`channelCount = 1`). Stereo with per-channel
|
||||||
* (Phase 2) extends this to a per-channel `freqHzL` / `freqHzR` pair.
|
* 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(
|
class SineWaveAudioCapture(
|
||||||
private val freqHz: Int = 440,
|
private val freqHz: Int = 440,
|
||||||
|
private val channelCount: Int = 1,
|
||||||
|
private val freqHzPerChannel: IntArray? = null,
|
||||||
private val amplitude: Short = 16_383,
|
private val amplitude: Short = 16_383,
|
||||||
) : AudioCapture {
|
) : AudioCapture {
|
||||||
|
init {
|
||||||
|
if (freqHzPerChannel != null) {
|
||||||
|
require(freqHzPerChannel.size == channelCount) {
|
||||||
|
"freqHzPerChannel.size (${freqHzPerChannel.size}) must equal channelCount ($channelCount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var sampleIdx: Long = 0L
|
private var sampleIdx: Long = 0L
|
||||||
|
|
||||||
/** Wallclock target for the next frame (`System.nanoTime` units). */
|
/** Wallclock target for the next frame (`System.nanoTime` units). */
|
||||||
@@ -55,15 +72,21 @@ class SineWaveAudioCapture(
|
|||||||
|
|
||||||
override suspend fun readFrame(): ShortArray? {
|
override suspend fun readFrame(): ShortArray? {
|
||||||
val samples = AudioFormat.FRAME_SIZE_SAMPLES
|
val samples = AudioFormat.FRAME_SIZE_SAMPLES
|
||||||
val out = ShortArray(samples)
|
val out = ShortArray(samples * channelCount)
|
||||||
val baseIdx = sampleIdx
|
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) {
|
for (i in 0 until samples) {
|
||||||
val v = (amplitude * sin(angularStep * (baseIdx + i))).toInt()
|
val t = (baseIdx + i).toDouble()
|
||||||
// Clamp defensively — amplitude is well below Short.MAX_VALUE
|
for (ch in 0 until channelCount) {
|
||||||
// by default, but a future bigger amplitude could otherwise
|
val freq = freqHzPerChannel?.get(ch) ?: freqHz
|
||||||
// wrap on the .toShort() truncation.
|
val v = (amplitude * sin(twoPi * freq * t / sampleRate)).toInt()
|
||||||
out[i] = v.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
|
// 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
|
sampleIdx = baseIdx + samples
|
||||||
|
|
||||||
|
|||||||
+197
-6
@@ -20,13 +20,16 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.nestsclient.interop.native
|
package com.vitorpamplona.nestsclient.interop.native
|
||||||
|
|
||||||
|
import com.vitorpamplona.nestsclient.AudioBroadcastConfig
|
||||||
import com.vitorpamplona.nestsclient.NestsClient
|
import com.vitorpamplona.nestsclient.NestsClient
|
||||||
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
||||||
import com.vitorpamplona.nestsclient.audio.AudioFormat
|
import com.vitorpamplona.nestsclient.audio.AudioFormat
|
||||||
|
import com.vitorpamplona.nestsclient.audio.JvmOpusDecoder
|
||||||
import com.vitorpamplona.nestsclient.audio.JvmOpusEncoder
|
import com.vitorpamplona.nestsclient.audio.JvmOpusEncoder
|
||||||
import com.vitorpamplona.nestsclient.audio.PcmAssertions
|
import com.vitorpamplona.nestsclient.audio.PcmAssertions
|
||||||
import com.vitorpamplona.nestsclient.audio.SineWaveAudioCapture
|
import com.vitorpamplona.nestsclient.audio.SineWaveAudioCapture
|
||||||
import com.vitorpamplona.nestsclient.buildRelayConnectTarget
|
import com.vitorpamplona.nestsclient.buildRelayConnectTarget
|
||||||
|
import com.vitorpamplona.nestsclient.connectNestsListener
|
||||||
import com.vitorpamplona.nestsclient.connectNestsSpeaker
|
import com.vitorpamplona.nestsclient.connectNestsSpeaker
|
||||||
import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker
|
import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker
|
||||||
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
|
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
|
||||||
@@ -473,6 +476,173 @@ class HangInteropTest {
|
|||||||
PcmAssertions.assertFftPeak(analysed, expectedHz = 440.0, halfWindowHz = 5.0)
|
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.
|
* Rust↔Rust round-trip: pure-Rust through our harness.
|
||||||
* Validates the cargo workspace + relay config + `moq-lite-03`
|
* Validates the cargo workspace + relay config + `moq-lite-03`
|
||||||
@@ -587,6 +757,12 @@ private suspend fun runSpeakerToHangListen(
|
|||||||
* the reconnect orchestrator.
|
* the reconnect orchestrator.
|
||||||
*/
|
*/
|
||||||
hotSwapAfterMs: Long? = null,
|
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 {
|
): HangListenOutput {
|
||||||
val harness = NativeMoqRelayHarness.shared()
|
val harness = NativeMoqRelayHarness.shared()
|
||||||
|
|
||||||
@@ -656,6 +832,18 @@ private suspend fun runSpeakerToHangListen(
|
|||||||
certificateValidator = PermissiveCertificateValidator(),
|
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
|
lateinit var listenProc: Process
|
||||||
try {
|
try {
|
||||||
val speaker =
|
val speaker =
|
||||||
@@ -667,8 +855,9 @@ private suspend fun runSpeakerToHangListen(
|
|||||||
room = room,
|
room = room,
|
||||||
signer = signer,
|
signer = signer,
|
||||||
speakerPubkeyHex = pubkey,
|
speakerPubkeyHex = pubkey,
|
||||||
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
|
captureFactory = captureFactory,
|
||||||
encoderFactory = { JvmOpusEncoder() },
|
encoderFactory = encoderFactory,
|
||||||
|
broadcastConfig = broadcastConfig,
|
||||||
tokenRefreshAfterMs = hotSwapAfterMs,
|
tokenRefreshAfterMs = hotSwapAfterMs,
|
||||||
connector = {
|
connector = {
|
||||||
connectNestsSpeaker(
|
connectNestsSpeaker(
|
||||||
@@ -678,8 +867,9 @@ private suspend fun runSpeakerToHangListen(
|
|||||||
room = room,
|
room = room,
|
||||||
signer = signer,
|
signer = signer,
|
||||||
speakerPubkeyHex = pubkey,
|
speakerPubkeyHex = pubkey,
|
||||||
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
|
captureFactory = captureFactory,
|
||||||
encoderFactory = { JvmOpusEncoder() },
|
encoderFactory = encoderFactory,
|
||||||
|
broadcastConfig = broadcastConfig,
|
||||||
framesPerGroup = 5,
|
framesPerGroup = 5,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -692,8 +882,9 @@ private suspend fun runSpeakerToHangListen(
|
|||||||
room = room,
|
room = room,
|
||||||
signer = signer,
|
signer = signer,
|
||||||
speakerPubkeyHex = pubkey,
|
speakerPubkeyHex = pubkey,
|
||||||
captureFactory = { SineWaveAudioCapture(freqHz = 440) },
|
captureFactory = captureFactory,
|
||||||
encoderFactory = { JvmOpusEncoder() },
|
encoderFactory = encoderFactory,
|
||||||
|
broadcastConfig = broadcastConfig,
|
||||||
framesPerGroup = 5,
|
framesPerGroup = 5,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user