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);