diff --git a/docs/operator-validation-runbook.md b/docs/operator-validation-runbook.md index ab343ae6..27b78628 100644 --- a/docs/operator-validation-runbook.md +++ b/docs/operator-validation-runbook.md @@ -156,12 +156,19 @@ Troubleshooting: 1. Ensure sign-in from step 8 (required for external-account meetings). 2. Enter meeting URL (and passcode if needed) in the join UI. -3. After join, verify: +3. Wait for **Zoom Live**, then turn **Capture** on. Joining establishes the + Meeting SDK session; Capture is the separate control that subscribes and + forwards raw participant video/audio into CoreVideo Pro. +4. Verify Capture is genuinely running before evaluating the show: + - `engineOn` is `true` in control state / diagnostics. + - Participant `video_frame_received` counters advance for every expected feed. + - Expected participant audio callbacks and meters advance when each guest speaks. +5. After join and Capture On, verify: - **Participants** roster populates (not empty / not stale stub). - **Program** tile shows video (GPU/full-res if D3D11 interop works, else CPU/BGRA preview). - **Breakout room** name updates when you move rooms. - Diagnostics show live Zoom capabilities when the dev SDK path is active (`zoom-raw-video`, `zoom-raw-audio`). -4. **Leave** meeting; roster clears, meeting state returns to idle. +6. **Leave** meeting; roster clears, meeting state returns to idle. ## 10. Headless live Zoom harness (optional) diff --git a/native-shell/CoreVideoPro.WinUI.Tests/AudioMeterScaleTests.cs b/native-shell/CoreVideoPro.WinUI.Tests/AudioMeterScaleTests.cs index c4a91df1..06c6fbf2 100644 --- a/native-shell/CoreVideoPro.WinUI.Tests/AudioMeterScaleTests.cs +++ b/native-shell/CoreVideoPro.WinUI.Tests/AudioMeterScaleTests.cs @@ -20,4 +20,36 @@ public void ToLevel_UsesCalibratedMinus60ToZeroDbfsScale(double dbfs, int expect [Fact] public void ToLevel_MutedAlwaysReportsSilence() => Assert.Equal(0, AudioMeterScale.ToLevel(-3, muted: true)); + + [Theory] + [InlineData(324)] + [InlineData(576)] + [InlineData(900)] + public void FitVerticalSegments_FillsAvailableWindowHeight(double availableHeight) + { + var layout = AudioMeterScale.FitVerticalSegments(availableHeight, 36); + + Assert.Equal(36, layout.SegmentCount); + Assert.Equal(availableHeight, layout.OccupiedSize, precision: 6); + } + + [Fact] + public void FitVerticalSegments_GrowsSegmentsInTallWindow() + { + var compact = AudioMeterScale.FitVerticalSegments(324, 36); + var tall = AudioMeterScale.FitVerticalSegments(576, 36); + + Assert.True(tall.SegmentSize > compact.SegmentSize); + Assert.True(tall.SegmentSize > 7); + } + + [Fact] + public void FitVerticalSegments_ReducesResolutionRatherThanOverflowingShortWindow() + { + var layout = AudioMeterScale.FitVerticalSegments(72, 36); + + Assert.True(layout.SegmentCount < 36); + Assert.True(layout.SegmentSize >= 2); + Assert.Equal(72, layout.OccupiedSize, precision: 6); + } } diff --git a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs index ae101425..dff6671c 100644 --- a/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs +++ b/native-shell/CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs @@ -253,26 +253,40 @@ private void RenderSegments() double segMain = IsVertical ? 7 : 4; if (availableMain > 0) { - if ((segMain + spacing) * count > availableMain) + if (IsVertical) { - spacing = 1; - } - var perSegment = (availableMain - spacing * (count - 1)) / count; - if (perSegment < segMain) - { - segMain = Math.Max(2, Math.Floor(perSegment)); - } - var fits = (int)Math.Floor((availableMain + spacing) / (segMain + spacing)); - if (fits < count && fits >= 4) - { - // Too small even at 2px segments: re-quantize onto fewer segments - // so the meter stays proportional instead of clipping. - count = fits; + // Keep the segment bar and the dB scale on the same responsive + // height instead of pinning a fixed-size bar to the bottom. + var layout = Models.AudioMeterScale.FitVerticalSegments(availableMain, count); + count = layout.SegmentCount; + segMain = layout.SegmentSize; + spacing = layout.Spacing; activeSegments = (int)Math.Round(level / 100.0 * count, MidpointRounding.AwayFromZero); peakSegment = _peakLevel > 0 ? (int)Math.Round(Math.Clamp(_peakLevel, 0, 100) / 100.0 * count, MidpointRounding.AwayFromZero) - 1 : -1; } + else + { + if ((segMain + spacing) * count > availableMain) + { + spacing = 1; + } + var perSegment = (availableMain - spacing * (count - 1)) / count; + if (perSegment < segMain) + { + segMain = Math.Max(2, Math.Floor(perSegment)); + } + var fits = (int)Math.Floor((availableMain + spacing) / (segMain + spacing)); + if (fits < count && fits >= 4) + { + count = fits; + activeSegments = (int)Math.Round(level / 100.0 * count, MidpointRounding.AwayFromZero); + peakSegment = _peakLevel > 0 + ? (int)Math.Round(Math.Clamp(_peakLevel, 0, 100) / 100.0 * count, MidpointRounding.AwayFromZero) - 1 + : -1; + } + } } var panel = new StackPanel diff --git a/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs b/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs index 5df1ba89..e19fcc7a 100644 --- a/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs +++ b/native-shell/CoreVideoPro.WinUI/Models/AudioMeterScale.cs @@ -23,4 +23,46 @@ public static int ToLevel(double dbfs, bool muted = false) var normalized = (dbfs - MinimumDbfs) / (MaximumDbfs - MinimumDbfs); return Math.Clamp((int)Math.Round(normalized * 100), 0, 100); } + + /// + /// Fits a segmented vertical meter to its current viewport. Full-size + /// consoles keep the requested resolution and grow the segment bodies; + /// compact windows reduce the segment count only when 2 px bodies no + /// longer fit. The returned stack always occupies the available height. + /// + public static AudioMeterSegmentLayout FitVerticalSegments(double availableHeight, int requestedCount) + { + var count = Math.Clamp(requestedCount, 8, 48); + if (!double.IsFinite(availableHeight) || availableHeight <= 0) + { + return new AudioMeterSegmentLayout(count, 7, 2); + } + + const double minimumSegmentSize = 2; + var spacing = availableHeight / count >= 5 ? 2d : 1d; + var segmentSize = (availableHeight - spacing * (count - 1)) / count; + + if (segmentSize < minimumSegmentSize) + { + spacing = 1; + count = Math.Min(count, Math.Max(1, + (int)Math.Floor((availableHeight + spacing) / (minimumSegmentSize + spacing)))); + + if (count == 1) + { + spacing = 0; + } + + segmentSize = Math.Max(0, + (availableHeight - spacing * (count - 1)) / count); + } + + return new AudioMeterSegmentLayout(count, segmentSize, spacing); + } +} + +public readonly record struct AudioMeterSegmentLayout(int SegmentCount, double SegmentSize, double Spacing) +{ + public double OccupiedSize => + SegmentCount * SegmentSize + Math.Max(0, SegmentCount - 1) * Spacing; } diff --git a/native/src/modules/AudioDsp.h b/native/src/modules/AudioDsp.h index f7aeca95..555a708e 100644 --- a/native/src/modules/AudioDsp.h +++ b/native/src/modules/AudioDsp.h @@ -57,6 +57,8 @@ inline std::vector coalescePcmAudioFramesBySource(std::vectorsecond]; if (target.sampleRate == frame.sampleRate && target.channels == frame.channels) { + target.requiresSteadyFeedPriming = + target.requiresSteadyFeedPriming || frame.requiresSteadyFeedPriming; target.pcm.insert(target.pcm.end(), frame.pcm.begin(), frame.pcm.end()); target.sampleCount = static_cast(target.pcm.size() / static_cast(target.channels)); continue; @@ -585,8 +587,66 @@ struct AudioFeedState { // 3.1% vs video before the pacer catch-up fix, silently). size_t shedSamples = 0; size_t shedEvents = 0; + // Zoom's SDK packet clock is 10 ms while the program worker is 20 ms. Prime + // to TWO complete worker ticks before the first emission, then retain the + // remaining tick as the same permanent cushion used by the video frame sync. + // Non-Zoom producers do not opt in and remain pass-through. + bool primed = false; + bool primingRequired = false; + size_t primeEvents = 0; + size_t partialBlocksPrevented = 0; + // The Zoom callback clock and the program worker clock are both nominally + // 48 kHz, but their scheduling phase is independent. A strict 960-frame + // dequeue lets the one-tick reserve random-walk to zero when polls alternate + // between 480 and 1440 frames. We therefore consume a few frames either + // side of nominal and resample that tiny window back to one exact output + // tick. This is the same elastic-jitter-buffer principle used by realtime + // playout engines: preserve cadence without inserting periodic 20-40 ms + // holes. The correction is bounded to 2.5% per tick. + size_t clockCorrectionEvents = 0; + size_t clockCorrectionInputFrames = 0; + size_t emptyInputTicks = 0; }; +inline std::vector resampleInterleavedBlockToFrames( + const float* input, size_t inputFrames, size_t outputFrames, size_t channels) { + std::vector output; + if (input == nullptr || inputFrames == 0 || outputFrames == 0 || channels == 0) { + return output; + } + output.resize(outputFrames * channels); + if (inputFrames == outputFrames) { + std::copy(input, input + inputFrames * channels, output.begin()); + return output; + } + if (inputFrames == 1 || outputFrames == 1) { + for (size_t frame = 0; frame < outputFrames; ++frame) { + for (size_t channel = 0; channel < channels; ++channel) { + output[frame * channels + channel] = input[channel]; + } + } + return output; + } + + // Map the complete input block onto the complete output block. Adjacent + // blocks remain ordered: after this call the FIFO erases exactly inputFrames + // and the next block begins at the following source frame. + const double scale = static_cast(inputFrames - 1) / + static_cast(outputFrames - 1); + for (size_t frame = 0; frame < outputFrames; ++frame) { + const double position = static_cast(frame) * scale; + const size_t base = static_cast(position); + const size_t next = (std::min)(base + 1, inputFrames - 1); + const float fraction = static_cast(position - static_cast(base)); + for (size_t channel = 0; channel < channels; ++channel) { + const float a = input[base * channels + channel]; + const float b = input[next * channels + channel]; + output[frame * channels + channel] = a + (b - a) * fraction; + } + } + return output; +} + inline void applyResumeFadeIn(AudioFeedState& state, float* interleaved, size_t samples, size_t channels) { if (state.fadeInRemaining == 0 || channels == 0 || samples == 0) { return; @@ -604,11 +664,12 @@ inline void applyResumeFadeIn(AudioFeedState& state, float* interleaved, size_t state.fadeInRemaining -= fadeFrames; } -// Block RESHAPER, zero added latency: whatever arrives is emitted immediately -// up to ONE tick of samples; any surplus (a tick that caught two packets) -// carries forward and fills the next short/empty tick. This converts the -// observed 480/1440/480 arrival jitter into steady full ticks without holding -// audio hostage (single bursts — and unit tests — pass straight through). +// Block reshaper. Ordinary sources retain zero-added-latency pass-through. +// Zoom sources opt into a permanent one-tick reserve: wait until two ticks are +// buffered, emit one exact tick, and leave one in reserve. This converts the +// observed 480/1440/480 arrival jitter into full 960-frame blocks without a +// 10 ms hole reaching the bus. A real starvation disarms the source so its next +// talk spurt re-primes instead of leaking another partial block. inline void steadyAudioFrameFeed(std::vector& frames, std::map& states, double ticksPerSecond = 50.0) { @@ -623,17 +684,71 @@ inline void steadyAudioFrameFeed(std::vector& frames, state.fifo.clear(); // format change: restart at the new layout state.sampleRate = frame.sampleRate; state.channels = frame.channels; + state.primed = false; } - if (!frame.pcm.empty()) { + state.primingRequired = state.primingRequired || frame.requiresSteadyFeedPriming; + const bool hadInputPcm = !frame.pcm.empty(); + if (hadInputPcm) { state.fifo.insert(state.fifo.end(), frame.pcm.begin(), frame.pcm.end()); } + if (state.primingRequired) { + state.emptyInputTicks = hadInputPcm ? 0 : state.emptyInputTicks + 1; + } const size_t tickSamples = static_cast(frame.sampleRate / ticksPerSecond) * static_cast(frame.channels); - const size_t emit = std::min(state.fifo.size(), tickSamples); - frame.pcm.assign(state.fifo.begin(), state.fifo.begin() + static_cast(emit)); + size_t emit = 0; + size_t consume = 0; + if (!state.primingRequired) { + emit = std::min(state.fifo.size(), tickSamples); + } else { + if (!state.primed && state.fifo.size() >= tickSamples * 2) { + state.primed = true; + ++state.primeEvents; + if (state.primeEvents == 1 || state.primeEvents % 100 == 0) { + std::fprintf(stderr, + "[audio] steady feed primed %s at %zu frames (%zu event%s)\n", + frame.participantId.c_str(), + state.fifo.size() / static_cast(frame.channels), + state.primeEvents, state.primeEvents == 1 ? "" : "s"); + } + } + if (state.primed && state.fifo.size() >= tickSamples) { + emit = tickSamples; + const size_t channels = static_cast(frame.channels); + const size_t tickFrames = tickSamples / channels; + const size_t availableFrames = state.fifo.size() / channels; + const size_t reserveFrames = tickFrames; + const size_t desiredFrames = availableFrames > reserveFrames + ? availableFrames - reserveFrames + : 0; + const size_t maxCorrectionFrames = (std::max)(size_t{1}, tickFrames / 40); + const size_t minConsumeFrames = tickFrames > maxCorrectionFrames + ? tickFrames - maxCorrectionFrames + : 1; + const size_t maxConsumeFrames = tickFrames + maxCorrectionFrames; + const size_t consumeFrames = (std::min)( + availableFrames, + (std::max)(minConsumeFrames, (std::min)(desiredFrames, maxConsumeFrames))); + consume = consumeFrames * channels; + } else if (!state.fifo.empty()) { + ++state.partialBlocksPrevented; + } + } + if (!state.primingRequired) { + consume = emit; + } + if (emit > 0 && consume != emit) { + frame.pcm = resampleInterleavedBlockToFrames( + state.fifo.data(), consume / static_cast(frame.channels), + emit / static_cast(frame.channels), static_cast(frame.channels)); + ++state.clockCorrectionEvents; + state.clockCorrectionInputFrames += consume / static_cast(frame.channels); + } else { + frame.pcm.assign(state.fifo.begin(), state.fifo.begin() + static_cast(emit)); + } frame.sampleCount = static_cast(emit / static_cast(frame.channels)); - state.fifo.erase(state.fifo.begin(), state.fifo.begin() + static_cast(emit)); + state.fifo.erase(state.fifo.begin(), state.fifo.begin() + static_cast(consume)); if (emit > 0 && state.lastEmitted == 0 && state.hasEverEmitted) { state.fadeInTotal = static_cast(0.005 * frame.sampleRate); state.fadeInRemaining = state.fadeInTotal; @@ -643,6 +758,10 @@ inline void steadyAudioFrameFeed(std::vector& frames, state.hasEverEmitted = true; } state.lastEmitted = emit; + if (state.primingRequired && emit == 0 && state.emptyInputTicks >= 2) { + state.fifo.clear(); + state.primed = false; + } // Cap runaway accumulation (device clock slightly fast): keep at most // 6 ticks buffered by dropping the OLDEST audio. The drop MUST be frame- @@ -682,20 +801,75 @@ inline void steadyAudioFrameFeed(std::vector& frames, if (seen) { continue; } + if (state.primingRequired) { + ++state.emptyInputTicks; + } if (state.fifo.empty()) { state.lastEmitted = 0; // dry: the NEXT flow onset gets a declick fade + if (state.primingRequired && state.emptyInputTicks >= 2) { + state.primed = false; + } continue; } const size_t tickSamples = static_cast(state.sampleRate / ticksPerSecond) * static_cast(state.channels); - const size_t emit = std::min(state.fifo.size(), tickSamples); + size_t emit = 0; + size_t consume = 0; + if (!state.primingRequired) { + emit = std::min(state.fifo.size(), tickSamples); + } else { + if (!state.primed && state.fifo.size() >= tickSamples * 2) { + state.primed = true; + ++state.primeEvents; + } + if (state.primed && state.fifo.size() >= tickSamples) { + emit = tickSamples; + const size_t channels = static_cast(state.channels); + const size_t tickFrames = tickSamples / channels; + const size_t availableFrames = state.fifo.size() / channels; + const size_t reserveFrames = tickFrames; + const size_t desiredFrames = availableFrames > reserveFrames + ? availableFrames - reserveFrames + : 0; + const size_t maxCorrectionFrames = (std::max)(size_t{1}, tickFrames / 40); + const size_t minConsumeFrames = tickFrames > maxCorrectionFrames + ? tickFrames - maxCorrectionFrames + : 1; + const size_t maxConsumeFrames = tickFrames + maxCorrectionFrames; + const size_t consumeFrames = (std::min)( + availableFrames, + (std::max)(minConsumeFrames, (std::min)(desiredFrames, maxConsumeFrames))); + consume = consumeFrames * channels; + } else { + ++state.partialBlocksPrevented; + } + } + if (emit == 0) { + state.lastEmitted = 0; + if (state.primingRequired && state.emptyInputTicks >= 2) { + state.fifo.clear(); + state.primed = false; + } + continue; + } AudioFrame fill; fill.participantId = sourceId; fill.sampleRate = state.sampleRate; fill.channels = state.channels; - fill.pcm.assign(state.fifo.begin(), state.fifo.begin() + static_cast(emit)); + if (!state.primingRequired) { + consume = emit; + } + if (consume != emit) { + fill.pcm = resampleInterleavedBlockToFrames( + state.fifo.data(), consume / static_cast(state.channels), + emit / static_cast(state.channels), static_cast(state.channels)); + ++state.clockCorrectionEvents; + state.clockCorrectionInputFrames += consume / static_cast(state.channels); + } else { + fill.pcm.assign(state.fifo.begin(), state.fifo.begin() + static_cast(emit)); + } fill.sampleCount = static_cast(emit / static_cast(state.channels)); - state.fifo.erase(state.fifo.begin(), state.fifo.begin() + static_cast(emit)); + state.fifo.erase(state.fifo.begin(), state.fifo.begin() + static_cast(consume)); if (emit > 0 && state.lastEmitted == 0 && state.hasEverEmitted) { state.fadeInTotal = static_cast(0.005 * state.sampleRate); state.fadeInRemaining = state.fadeInTotal; diff --git a/native/src/modules/Interfaces.h b/native/src/modules/Interfaces.h index 0f91f49a..ad43444e 100644 --- a/native/src/modules/Interfaces.h +++ b/native/src/modules/Interfaces.h @@ -114,6 +114,12 @@ struct AudioFrame { double peakLevel = 0.0; double noiseFloorDb = -60.0; bool voiceActive = true; + // Zoom raw audio arrives as 10 ms SDK packets while the program mixer runs + // on a 20 ms clock. Those independent clocks need a permanent one-tick + // cushion, just like Zoom video keeps a one-frame sync cushion. Producers + // that set this flag opt into full-tick priming in steadyAudioFrameFeed; + // local/media/capture sources keep their existing zero-latency behavior. + bool requiresSteadyFeedPriming = false; // Optional interleaved float PCM payload in full-scale range [-1, 1] with // `channels` channels (so `pcm.size()` is `sampleCount * channels` when // present). When non-empty, the audio DSP core measures real RMS/peak from diff --git a/native/src/modules/ZoomEngineRuntime.cpp b/native/src/modules/ZoomEngineRuntime.cpp index 080f996b..5338304a 100644 --- a/native/src/modules/ZoomEngineRuntime.cpp +++ b/native/src/modules/ZoomEngineRuntime.cpp @@ -409,6 +409,12 @@ std::vector ZoomEngineRuntime::pollCompositorAudioFrames(int64_t tim drainAudioStreamLocked(uuid, ref); } auto frames = state_.pollCompositorAudioFrames(timestampMs); + for (auto& frame : frames) { + // Zoom delivers 10 ms raw packets into a 20 ms program clock. Opt every + // Zoom channel (meeting mix and participant ISO) into the full-tick feed + // cushion; other audio producers keep their zero-latency behavior. + frame.requiresSteadyFeedPriming = true; + } // Overlay real decoded PCM: a participant with pending audio gets ONE // coalesced PCM frame (all samples ingested since the last poll), replacing // the metadata-only placeholder the state emits from packet counters. @@ -424,6 +430,7 @@ std::vector ZoomEngineRuntime::pollCompositorAudioFrames(int64_t tim frame.channels = pending.channels; frame.timestampMs = timestampMs; frame.sampleCount = static_cast(pending.pcm.size() / static_cast(pending.channels)); + frame.requiresSteadyFeedPriming = true; frame.pcm = std::move(pending.pcm); pending.pcm.clear(); // defined-empty after the move const auto existing = std::find_if(frames.begin(), frames.end(), [&](const AudioFrame& candidate) { diff --git a/native/tests/AudioDspTest.cpp b/native/tests/AudioDspTest.cpp index 8b5bb6dc..f2a33b0e 100644 --- a/native/tests/AudioDspTest.cpp +++ b/native/tests/AudioDspTest.cpp @@ -1052,6 +1052,132 @@ TEST(AudioDsp, SteadyFeedReshapesJitteryPacketsWithoutHoldingAudio) { } } +TEST(AudioDsp, ZoomSteadyFeedPrimesOneTickAndNeverEmitsPartialBlocks) { + std::map states; + float sequence = 0.0f; + const auto makeFrame = [&sequence](int samples) { + corevideo::modules::AudioFrame frame; + frame.participantId = "zoom-iso"; + frame.sampleRate = 48000; + frame.channels = 1; + frame.requiresSteadyFeedPriming = true; + for (int index = 0; index < samples; ++index) { + frame.pcm.push_back(sequence++); + } + frame.sampleCount = samples; + return frame; + }; + + std::vector emitted; + const auto tick = [&](int packetSamples) { + std::vector frames; + if (packetSamples > 0) { + frames.push_back(makeFrame(packetSamples)); + } + corevideo::modules::steadyAudioFrameFeed(frames, states); + for (const auto& frame : frames) { + if (!frame.pcm.empty()) { + EXPECT_EQ(frame.pcm.size(), 960u); + emitted.insert(emitted.end(), frame.pcm.begin(), frame.pcm.end()); + } + } + }; + + tick(480); // first 10 ms SDK packet is held, never sent as a short bus + EXPECT_TRUE(emitted.empty()); + tick(1440); // two ticks buffered: emit 20 ms and retain a 20 ms cushion + EXPECT_EQ(emitted.size(), 960u); + tick(480); // 10 ms arrival + cushion still emits one complete tick + tick(480); + EXPECT_EQ(emitted.size(), 2880u); + for (size_t index = 1; index < emitted.size(); ++index) { + EXPECT_GE(emitted[index], emitted[index - 1]); + } + EXPECT_TRUE(states["zoom-iso"].primed); + EXPECT_EQ(states["zoom-iso"].primeEvents, 1u); + EXPECT_GE(states["zoom-iso"].partialBlocksPrevented, 1u); + EXPECT_GE(states["zoom-iso"].clockCorrectionEvents, 1u); +} + +TEST(AudioDsp, ZoomSteadyFeedDoesNotReprimeUnderContinuousClockPhaseJitter) { + // Live Zoom reproduction (Jamal, 2026-08-15): the SDK supplies 10 ms + // packets while the program worker polls every 20 ms. Depending on phase, + // a poll sees 480 then 1440 frames even though the source is continuous. + // The old strict dequeue drained its reserve and re-primed ~16 times/sec, + // inserting an audible hole each time. + std::map states; + float sequence = 0.0f; + const auto makeFrame = [&sequence](int samples) { + corevideo::modules::AudioFrame frame; + frame.participantId = "jamal"; + frame.sampleRate = 48000; + frame.channels = 1; + frame.requiresSteadyFeedPriming = true; + frame.pcm.reserve(static_cast(samples)); + for (int index = 0; index < samples; ++index) { + frame.pcm.push_back(sequence++); + } + frame.sampleCount = samples; + return frame; + }; + + size_t fullBlocks = 0; + for (int tick = 0; tick < 2000; ++tick) { + // Equal long-term rate (960/tick), deliberately hostile poll phase. + const int arrivals[] = {480, 1440, 480, 1440, 960, 960}; + std::vector frames; + frames.push_back(makeFrame(arrivals[tick % 6])); + corevideo::modules::steadyAudioFrameFeed(frames, states); + if (!frames[0].pcm.empty()) { + EXPECT_EQ(frames[0].pcm.size(), 960u); + ++fullBlocks; + } + } + + EXPECT_EQ(states["jamal"].primeEvents, 1u); + EXPECT_TRUE(states["jamal"].primed); + EXPECT_GE(fullBlocks, 1998u); // only startup priming may be silent + EXPECT_GT(states["jamal"].clockCorrectionEvents, 0u); +} + +TEST(AudioDsp, ZoomSteadyFeedReprimesAfterARealGap) { + std::map states; + const auto makeFrame = [](int samples) { + corevideo::modules::AudioFrame frame; + frame.participantId = "zoom-iso"; + frame.sampleRate = 48000; + frame.channels = 1; + frame.requiresSteadyFeedPriming = true; + frame.pcm.assign(static_cast(samples), 0.25f); + frame.sampleCount = samples; + return frame; + }; + const auto tick = [&](int samples) { + std::vector frames; + frames.push_back(makeFrame(samples)); + corevideo::modules::steadyAudioFrameFeed(frames, states); + return frames; + }; + + (void)tick(960); // held for the cushion + auto out = tick(960); // first exact block + ASSERT_EQ(out[0].pcm.size(), 960u); + out = tick(0); // drain the reserve + ASSERT_EQ(out[0].pcm.size(), 960u); + out = tick(0); // genuinely dry: disarm + EXPECT_TRUE(out[0].pcm.empty()); + EXPECT_FALSE(states["zoom-iso"].primed); + + out = tick(480); // resumed half-block is held + EXPECT_TRUE(out[0].pcm.empty()); + out = tick(1440); // re-prime to two ticks, then emit exactly one + ASSERT_EQ(out[0].pcm.size(), 960u); + EXPECT_TRUE(states["zoom-iso"].primed); + EXPECT_EQ(states["zoom-iso"].primeEvents, 2u); + EXPECT_LT(out[0].pcm[0], 0.02f); // existing 5 ms resume fade still applies + EXPECT_EQ(out[0].pcm[400], 0.25f); +} + TEST(SpscRing, PushPopWrapFullAndDryProperties) { corevideo::modules::SpscRing ring(8); // tiny capacity to force wrap + full float in[16]; diff --git a/native/tests/ZoomEngineRuntimeTest.cpp b/native/tests/ZoomEngineRuntimeTest.cpp index 5df3aa4d..d6309cae 100644 --- a/native/tests/ZoomEngineRuntimeTest.cpp +++ b/native/tests/ZoomEngineRuntimeTest.cpp @@ -525,6 +525,7 @@ TEST(ZoomEngineRuntime, IngestsIsoAudioPcmFromSharedMemoryIntoCompositorPoll) { EXPECT_EQ(found->sampleRate, 48000); EXPECT_EQ(found->channels, 1); EXPECT_EQ(found->sampleCount, 4); + EXPECT_TRUE(found->requiresSteadyFeedPriming); ASSERT_TRUE(found->pcm.size() == 4u); EXPECT_EQ(found->pcm[1], 0.5f); EXPECT_EQ(found->pcm[2], -0.5f);