From 2ae4dcc3d02236dde032237a70b6658bae0c03c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:25:24 +0800 Subject: [PATCH] fix(windows): preserve non-integer resampling continuity across packets --- .../wgc-capture/src/audio_sample_utils.cpp | 444 +++++- .../wgc-capture/src/audio_sample_utils.h | 145 ++ .../src/audio_sample_utils_test.cpp | 1381 ++++++++++++++++- .../testing/manual-e2e-checklist.md | 1 + 4 files changed, 1950 insertions(+), 21 deletions(-) diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index 4ffe3ff50..07d35a162 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -105,6 +105,100 @@ UINT32 aacCompatibleSampleRate(UINT32 sampleRate) { return 48000; } +// --- Checked arithmetic for the interpolation stream's cumulative counters --- +// (issue #920, item 24). The API accepts arbitrary DWORD-sized packets and +// UINT32 rates, so the products below can in principle exceed 64 bits; a +// wrapped counter would silently corrupt the stream geometry, so overflow +// parks the state in a mode that emits nothing until reset() instead. + +bool addChecked(uint64_t& out, uint64_t a, uint64_t b) { + if (a > UINT64_MAX - b) { + return false; + } + out = a + b; + return true; +} + +bool mulChecked(uint64_t& out, uint64_t a, uint64_t b) { + if (a != 0 && b > UINT64_MAX / a) { + return false; + } + out = a * b; + return true; +} + +// K(N) = max(1, round_half_up(N*T/S)), computed by decomposing N over S so the +// only products ever formed are (N mod S)*T and 2*(N mod S)*T — both checked, +// never an unchecked N*T. +bool interpolationQuota(uint64_t& out, uint64_t inputFrames, UINT32 sourceRate, UINT32 targetRate) { + const uint64_t whole = inputFrames / sourceRate; + const uint64_t rem = inputFrames % sourceRate; + uint64_t wholeTargets = 0; + if (!mulChecked(wholeTargets, whole, targetRate)) { + return false; + } + uint64_t remTargets = 0; + if (rem != 0) { + uint64_t remTimesTarget = 0; + if (!mulChecked(remTimesTarget, rem, targetRate)) { + return false; + } + // round_half_up(p/S) = (2p + S) / (2S) + uint64_t twiceP = 0; + uint64_t numer = 0; + if (!addChecked(twiceP, remTimesTarget, remTimesTarget) || + !addChecked(numer, twiceP, sourceRate)) { + return false; + } + remTargets = numer / (2 * static_cast(sourceRate)); + } + uint64_t quota = 0; + if (!addChecked(quota, wholeTargets, remTargets)) { + return false; + } + out = quota < 1 ? 1 : quota; + return true; +} + +// C(N) = min(K(N), ceil((N-1)*T/S)) for T != S: the largest output count whose +// every interpolation window is complete. When the rates are equal every +// frame emits immediately (frac == 0 makes the clamped window exact), so C is +// the full quota and per-packet counts match the pre-fix converter. +bool interpolationEmittableCount( + uint64_t& out, + uint64_t inputFrames, + uint64_t quota, + UINT32 sourceRate, + UINT32 targetRate) { + if (sourceRate == targetRate) { + out = quota; + return true; + } + const uint64_t prior = inputFrames - 1; + const uint64_t whole = prior / sourceRate; + const uint64_t rem = prior % sourceRate; + uint64_t wholeTargets = 0; + if (!mulChecked(wholeTargets, whole, targetRate)) { + return false; + } + uint64_t remCeil = 0; + if (rem != 0) { + uint64_t remTimesTarget = 0; + uint64_t numer = 0; + if (!mulChecked(remTimesTarget, rem, targetRate) || + !addChecked(numer, remTimesTarget, sourceRate - 1)) { + return false; + } + remCeil = numer / sourceRate; + } + uint64_t emittable = 0; + if (!addChecked(emittable, wholeTargets, remCeil)) { + return false; + } + out = emittable < quota ? emittable : quota; + return true; +} + } // namespace constexpr int64_t HnsPerSecond = 10'000'000; @@ -184,6 +278,56 @@ void AudioDecimatorState::reset() { std::fill(history_.begin(), history_.end(), 0.0); position_ = 0; phase_ = 0; + // The interpolation component resets cold with the integer one. A reset + // drops any unfinalized resampling tail — at most ceil(T/S + 1/2) output + // frames, nothing at all on the same-rate path (see the class comment). + mode_ = Mode::kNone; + interpolationFinalized_ = false; + interpolationOverflow_ = false; + interpolationSourceSubtype_ = {}; + interpolationTargetSubtype_ = {}; + interpolationSourceRate_ = 0; + interpolationTargetRate_ = 0; + interpolationSourceChannels_ = 0; + interpolationTargetChannels_ = 0; + interpolationSourceBits_ = 0; + interpolationTargetBits_ = 0; + interpolationInputFrames_ = 0; + interpolationEmittedFrames_ = 0; + interpolationTotalFrames_ = 0; + interpolationEmittableFrames_ = 0; + interpolationHistoryBase_ = 0; + interpolationRetained_.clear(); + interpolationRetained_.shrink_to_fit(); +} + +void AudioDecimatorState::abandonInterpolation() { + // A starvation resume is a real-time discontinuity: drop exactly the + // interpolation component reset() drops (see its comment for the bounded + // tail this discards) and nothing else — the integer decimation fields + // (taps_/history_/position_/phase_/factor_/channels_) keep their + // pre-existing cross-gap behavior, matching the pre-fix mixer. + if (mode_ != Mode::kInterpolation) { + return; + } + mode_ = Mode::kNone; + interpolationFinalized_ = false; + interpolationOverflow_ = false; + interpolationSourceSubtype_ = {}; + interpolationTargetSubtype_ = {}; + interpolationSourceRate_ = 0; + interpolationTargetRate_ = 0; + interpolationSourceChannels_ = 0; + interpolationTargetChannels_ = 0; + interpolationSourceBits_ = 0; + interpolationTargetBits_ = 0; + interpolationInputFrames_ = 0; + interpolationEmittedFrames_ = 0; + interpolationTotalFrames_ = 0; + interpolationEmittableFrames_ = 0; + interpolationHistoryBase_ = 0; + interpolationRetained_.clear(); + interpolationRetained_.shrink_to_fit(); } void AudioDecimatorState::prepare(UINT32 factor, UINT32 channels) { @@ -249,6 +393,164 @@ bool AudioDecimatorState::consume(const double* frame, double* out) { return produced; } +bool AudioDecimatorState::prepareInterpolation( + const AudioInputFormat& source, + const AudioInputFormat& target) { + if (interpolationFinalized_ || interpolationOverflow_) { + // Contract: a finalized (or overflow-parked) stream accepts no more + // input until reset() or a fresh state object. + return false; + } + if (mode_ == Mode::kInterpolation && + source.sampleRate == interpolationSourceRate_ && + target.sampleRate == interpolationTargetRate_ && + source.channels == interpolationSourceChannels_ && + target.channels == interpolationTargetChannels_ && + source.bitsPerSample == interpolationSourceBits_ && + target.bitsPerSample == interpolationTargetBits_ && + IsEqualGUID(source.subtype, interpolationSourceSubtype_) && + IsEqualGUID(target.subtype, interpolationTargetSubtype_)) { + return true; // the same stream continues across the packet boundary + } + if (mode_ == Mode::kInteger) { + // Entering interpolation from integer decimation clears the integer + // history and remainder ONCE per transition (the pre-fix code reset + // per packet; the cross-format cold-start contract is what stays). + std::fill(history_.begin(), history_.end(), 0.0); + position_ = 0; + phase_ = 0; + } + mode_ = Mode::kInterpolation; + interpolationSourceSubtype_ = source.subtype; + interpolationTargetSubtype_ = target.subtype; + interpolationSourceRate_ = source.sampleRate; + interpolationTargetRate_ = target.sampleRate; + interpolationSourceChannels_ = source.channels; + interpolationTargetChannels_ = target.channels; + interpolationSourceBits_ = source.bitsPerSample; + interpolationTargetBits_ = target.bitsPerSample; + interpolationInputFrames_ = 0; + interpolationEmittedFrames_ = 0; + interpolationTotalFrames_ = 0; + interpolationEmittableFrames_ = 0; + interpolationHistoryBase_ = 0; + interpolationRetained_.clear(); + return true; +} + +void AudioDecimatorState::beginIntegerDecimation() { + if (mode_ == Mode::kInteger) { + return; + } + if (mode_ == Mode::kInterpolation) { + // Entering integer decimation from interpolation discards the + // interpolation history and starts the integer filter cold, preserving + // the cross-format reset contract the suite pins. + std::fill(history_.begin(), history_.end(), 0.0); + position_ = 0; + phase_ = 0; + interpolationFinalized_ = false; + interpolationOverflow_ = false; + interpolationInputFrames_ = 0; + interpolationEmittedFrames_ = 0; + interpolationTotalFrames_ = 0; + interpolationEmittableFrames_ = 0; + interpolationHistoryBase_ = 0; + interpolationRetained_.clear(); + interpolationRetained_.shrink_to_fit(); + } + mode_ = Mode::kInteger; +} + +void AudioDecimatorState::appendInterpolationFrame(const double* mappedFrame, UINT32 channels) { + interpolationRetained_.insert(interpolationRetained_.end(), mappedFrame, mappedFrame + channels); + interpolationInputFrames_ += 1; + uint64_t quota = 0; + if (!interpolationQuota(quota, interpolationInputFrames_, interpolationSourceRate_, interpolationTargetRate_)) { + interpolationOverflow_ = true; + return; + } + interpolationTotalFrames_ = quota; + uint64_t emittable = 0; + if (!interpolationEmittableCount( + emittable, interpolationInputFrames_, quota, interpolationSourceRate_, + interpolationTargetRate_)) { + interpolationOverflow_ = true; + return; + } + interpolationEmittableFrames_ = emittable; +} + +size_t AudioDecimatorState::interpolationRetainedFrames() const { + const UINT32 channels = interpolationTargetChannels_ == 0 ? 1 : interpolationTargetChannels_; + return interpolationRetained_.size() / channels; +} + +double AudioDecimatorState::interpolationRetainedSample(uint64_t absoluteFrame, UINT32 channel) const { + if (absoluteFrame < interpolationHistoryBase_ || channel >= interpolationTargetChannels_) { + return 0.0; + } + const uint64_t offset = + (absoluteFrame - interpolationHistoryBase_) * interpolationTargetChannels_ + channel; + if (offset >= interpolationRetained_.size()) { + return 0.0; + } + return interpolationRetained_[static_cast(offset)]; +} + +void AudioDecimatorState::noteInterpolationEmitted(uint64_t throughFrameCount) { + if (throughFrameCount > interpolationEmittedFrames_) { + interpolationEmittedFrames_ = throughFrameCount; + } +} + +void AudioDecimatorState::trimInterpolationHistory() { + if (mode_ != Mode::kInterpolation || interpolationInputFrames_ == 0) { + return; + } + // The next output the stream owes is `interpolationEmittedFrames_`. The + // earliest source frame ANY future output can read is + // floor(next * S / T) (exact rational), minus one frame of allowance for + // the legacy double-precision index calculation; everything before that is + // dead. What remains after this trim is bounded by ceil(S/(2T)) + 2 frames + // (see the class comment) — never by stream length or packet count. + const uint64_t next = interpolationEmittedFrames_; + const uint64_t last = interpolationInputFrames_ - 1; + uint64_t floorPos = 0; + { + // floor(next*S/T) via the same decomposition as the quota: the only + // products formed are (next div T)*S and (next mod T)*S. + const uint64_t whole = next / interpolationTargetRate_; + const uint64_t rem = next % interpolationTargetRate_; + uint64_t wholeTimesSource = 0; + uint64_t remTimesSource = 0; + if (!mulChecked(wholeTimesSource, whole, interpolationSourceRate_) || + !mulChecked(remTimesSource, rem, interpolationSourceRate_)) { + return; // cannot bound safely; stay correct and retain instead + } + floorPos = wholeTimesSource + remTimesSource / interpolationTargetRate_; + } + if (floorPos > last) { + floorPos = last; + } + const uint64_t keepFrom = floorPos > 0 ? floorPos - 1 : 0; + if (keepFrom <= interpolationHistoryBase_) { + return; + } + const uint64_t drop = keepFrom - interpolationHistoryBase_; + const UINT32 channels = interpolationTargetChannels_; + interpolationRetained_.erase( + interpolationRetained_.begin(), + interpolationRetained_.begin() + static_cast(drop * channels)); + interpolationHistoryBase_ = keepFrom; +} + +void AudioDecimatorState::markInterpolationFinalized() { + interpolationFinalized_ = true; + interpolationRetained_.clear(); + interpolationRetained_.shrink_to_fit(); +} + void copyAudioWithGain( const BYTE* source, DWORD byteCount, @@ -309,6 +611,9 @@ void convertAudioWithGain( AudioDecimatorState discardedDecimator; convertAudioWithGain( source, byteCount, sourceFormat, targetFormat, gain, destination, discardedDecimator); + // One-shot semantics: everything the stream owes, including the deferred + // tail, in the pre-fix endpoint-clamped order. + finalizeAudioConversion(targetFormat, gain, destination, discardedDecimator); } void convertAudioWithGain( @@ -374,6 +679,9 @@ void convertAudioWithGain( if (sourceFormat.sampleRate > targetFormat.sampleRate && sourceFormat.sampleRate % targetFormat.sampleRate == 0) { const UINT32 factor = sourceFormat.sampleRate / targetFormat.sampleRate; + // A state that was interpolating starts decimation cold exactly once + // per transition; a state already decimating keeps its history. + decimator.beginIntegerDecimation(); decimator.prepare(factor, targetFormat.channels); const size_t maxTargetFrames = (packetFrames + decimator.pendingFrames()) / factor; destination.assign(maxTargetFrames * targetFormat.blockAlign, 0); @@ -402,36 +710,128 @@ void convertAudioWithGain( return; } - // Same ownership rule as the pass-through above, but unlike that one this - // branch is hot. A 48 kHz microphone against a 48 kHz PCM16 target fails - // sameAudioFormatForMixing on subtype alone -- WASAPI hands out float32 -- - // and `sourceRate > targetRate` is false, so on an ordinary machine every - // microphone packet lands here and resets a decimator it never used. - decimator.reset(); - const size_t sourceFrames = packetFrames; + // Non-integer (and same-rate cross-subtype) conversion. This branch used + // to reset `decimator` and restart its phase at every packet, so its output + // depended on where the packet boundaries fell — 100-frame packets at + // 48 kHz -> 44.1 kHz turned a 14700-frame stream into 14720 frames, and + // every sample after the first packet sat at a drifting position (issue + // #920, item 24). The state now carries the stream: output frame j always + // uses the ABSOLUTE stream position j / rateRatio with the interpolation + // arithmetic below unchanged, frames whose window is incomplete are + // deferred (bounded by ceil(T/S + 1/2)), and finalizeAudioConversion() + // flushes the tail with the old whole-stream endpoint clamping. + // + // Same-rate (T == S) conversion — every ordinary microphone packet, WASAPI + // float32 against a PCM16 target — emits each frame immediately instead: + // frac == 0 makes the clamped window exact, so per-packet output counts + // stay identical to the pre-fix converter and nothing is held back. For + // every OTHER ratio the uniform deferral applies; an unfinalized tail of + // at most ceil(T/S + 1/2) frames is dropped by reset() — and therefore by a + // mixer resume or the end of a take — unless the stream is finalized. + if (!decimator.prepareInterpolation(sourceFormat, targetFormat)) { + // Either the active stream was finalized (it accepts no more input + // until reset() or a fresh state) or its counters overflowed checked + // arithmetic. Refuse rather than corrupt the stream geometry. + destination.clear(); + return; + } + const UINT32 targetChannels = targetFormat.channels; const double rateRatio = static_cast(targetFormat.sampleRate) / static_cast(sourceFormat.sampleRate); - const size_t targetFrames = std::max(1, static_cast(std::llround(sourceFrames * rateRatio))); - destination.assign(targetFrames * targetFormat.blockAlign, 0); + std::vector mapped(targetChannels, 0.0); + for (size_t sourceFrame = 0; sourceFrame < packetFrames; ++sourceFrame) { + for (UINT32 channel = 0; channel < targetChannels; ++channel) { + mapped[channel] = readMappedChannel( + source, sourceFormat, sourceFrame, channel, targetChannels); + } + decimator.appendInterpolationFrame(mapped.data(), targetChannels); + } + const uint64_t emittedBefore = decimator.interpolationEmittedFrames(); + const uint64_t emittableNow = decimator.interpolationEmittableFrames(); + if (emittableNow < emittedBefore || + emittableNow - emittedBefore > static_cast(SIZE_MAX) / targetFormat.blockAlign) { + destination.clear(); + return; + } + const size_t newFrames = static_cast(emittableNow - emittedBefore); + destination.assign(newFrames * targetFormat.blockAlign, 0); + // The old loop, verbatim except that j is the ABSOLUTE stream output index + // and a/b come from the state's retained (channel-mapped, unscaled) frames + // instead of a packet-local buffer. + const size_t lastSourceFrame = static_cast(decimator.interpolationInputFrames() - 1); + for (uint64_t targetFrame = emittedBefore; targetFrame < emittableNow; ++targetFrame) { + const double sourcePosition = static_cast(targetFrame) / rateRatio; + const size_t sourceFrame = std::min(lastSourceFrame, static_cast(sourcePosition)); + const size_t nextFrame = std::min(lastSourceFrame, sourceFrame + 1); + const double frac = sourcePosition - static_cast(sourceFrame); + for (UINT32 channel = 0; channel < targetChannels; ++channel) { + const double a = decimator.interpolationRetainedSample(sourceFrame, channel); + const double b = decimator.interpolationRetainedSample(nextFrame, channel); + writeSampleFromDouble( + destination.data(), + targetFormat, + static_cast(targetFrame - emittedBefore), + channel, + (a + (b - a) * frac) * gain); + } + } + decimator.noteInterpolationEmitted(emittableNow); + decimator.trimInterpolationHistory(); +} - for (size_t targetFrame = 0; targetFrame < targetFrames; ++targetFrame) { +void finalizeAudioConversion( + const AudioInputFormat& targetFormat, + double gain, + std::vector& destination, + AudioDecimatorState& decimator) { + // Only the non-integer interpolation stream has a deferred tail. Same-rate + // conversion emitted everything immediately and the integer/same-format + // paths keep their own accounting, so for those this is a no-op that must + // not touch their remainder/history behavior. + if (!decimator.interpolationActive() || decimator.interpolationFinalized()) { + return; + } + const uint64_t inputFrames = decimator.interpolationInputFrames(); + const uint64_t totalFrames = decimator.interpolationTotalFrames(); + const uint64_t emittedFrames = decimator.interpolationEmittedFrames(); + if (inputFrames == 0 || totalFrames <= emittedFrames) { + // An empty stream (or one that owes nothing) finalizes to no bytes. + decimator.markInterpolationFinalized(); + return; + } + if (targetFormat.blockAlign == 0 || targetFormat.channels == 0 || + totalFrames - emittedFrames > static_cast(SIZE_MAX) / targetFormat.blockAlign) { + decimator.markInterpolationFinalized(); + return; + } + const size_t tailFrames = static_cast(totalFrames - emittedFrames); + const size_t tailBytes = tailFrames * targetFormat.blockAlign; + const size_t existingBytes = destination.size(); + const size_t existingFrames = existingBytes / targetFormat.blockAlign; + destination.resize(existingBytes + tailBytes, 0); + // The pre-fix whole-stream computation, verbatim: absolute j, endpoint + // clamp at the stream's last input frame, the same interpolation + // expression and operation order as the old one-shot conversion. + const size_t lastSourceFrame = static_cast(inputFrames - 1); + const double rateRatio = decimator.interpolationRateRatio(); + for (uint64_t targetFrame = emittedFrames; targetFrame < totalFrames; ++targetFrame) { const double sourcePosition = static_cast(targetFrame) / rateRatio; - const size_t sourceFrame = std::min(sourceFrames - 1, static_cast(sourcePosition)); - const size_t nextFrame = std::min(sourceFrames - 1, sourceFrame + 1); + const size_t sourceFrame = std::min(lastSourceFrame, static_cast(sourcePosition)); + const size_t nextFrame = std::min(lastSourceFrame, sourceFrame + 1); const double frac = sourcePosition - static_cast(sourceFrame); for (UINT32 channel = 0; channel < targetFormat.channels; ++channel) { - const double a = readMappedChannel( - source, sourceFormat, sourceFrame, channel, targetFormat.channels); - const double b = readMappedChannel( - source, sourceFormat, nextFrame, channel, targetFormat.channels); + const double a = decimator.interpolationRetainedSample(sourceFrame, channel); + const double b = decimator.interpolationRetainedSample(nextFrame, channel); writeSampleFromDouble( destination.data(), targetFormat, - targetFrame, + existingFrames + static_cast(targetFrame - emittedFrames), channel, (a + (b - a) * frac) * gain); } } + decimator.noteInterpolationEmitted(totalFrames); + decimator.markInterpolationFinalized(); } void mixAudioInPlace( @@ -638,6 +1038,16 @@ void AudioMixer::append( return; } + // A starved source resuming is a real-time discontinuity: the starved + // branch below re-anchors its packet at now, after padding silence. The + // cross-packet interpolation stream must not complete deferred windows + // across the gap with retained pre-gap samples. Integer decimation and + // every non-interpolating state keep their pre-existing cross-gap + // behavior; abandonInterpolation() is a no-op there. + if (starved) { + decimator.abandonInterpolation(); + } + convertAudioWithGain(data, byteCount, sourceFormat, format_, gain, gainBuffer_, decimator); // A source that ran dry is coming back: loopback after a silence, or a device // that stalled for longer than the cushion. Its packet belongs at now, which diff --git a/electron/native/wgc-capture/src/audio_sample_utils.h b/electron/native/wgc-capture/src/audio_sample_utils.h index 81d99d021..b1dafec4c 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.h +++ b/electron/native/wgc-capture/src/audio_sample_utils.h @@ -35,6 +35,43 @@ void convertAudioWithGain( // current output group the stream has got, which is the same accounting the // caller used to read off a leftover-bytes buffer: a group only produces an // output frame once all `factor` of its frames have arrived. +// +// The same object also carries the NON-INTEGER conversion stream across +// packets (issue #920, item 24): cumulative frame accounting with absolute +// output positions, and a bounded retention of channel-mapped source frames +// for the interpolation windows. Output frame j of a stream always uses the +// position j * (source rate / target rate) with the interpolation arithmetic +// this file has always used, never a packet-local phase, so identical input +// prefixes produce identical output prefixes regardless of the partition. +// +// Deferral contract, per rate pair (S = source rate, T = target rate): +// * T != S: an output frame whose interpolation window touches the newest +// source frame is deferred until the window completes or the stream is +// finalized, so at most ceil(T/S + 1/2) output frames are outstanding at +// any time (2 for 48 kHz <-> 44.1 kHz). finalizeAudioConversion() emits +// the tail with the old whole-stream endpoint clamping; if a stream is +// NEVER finalized, that bounded tail is dropped by reset() — and therefore +// by a mixer resume or the end of a take, both of which reset their +// sources. Packetized per-packet emission counts differ from the pre-fix +// code by the deferred frames while the stream content itself is +// identical and drift-free. +// * T == S (every ordinary microphone packet: WASAPI float32 against a +// PCM16 target fails sameAudioFormatForMixing on subtype alone): each +// frame emits immediately — frac == 0 makes the clamped window exact — so +// per-packet output counts stay identical to the pre-fix converter and +// no frame is ever held, dropped or deferred on this path. +// +// Retained source history is bounded by rate geometry, not by stream length: +// after each packet's emission at most ceil(S/(2T)) + 2 mapped frames are +// kept (the allowance covers the legacy double-precision index calculation). +// +// Transitions preserve the pre-fix cross-format contract: entering the +// interpolation path from integer decimation clears the integer history and +// remainder once (not once per packet), and entering integer decimation from +// interpolation discards the interpolation history and starts the integer +// filter cold. A DIFFERENT format pair on the same state starts a fresh, +// cold stream. A finalized stream accepts no more conversion input until +// reset() or a fresh state object. class AudioDecimatorState { public: void reset(); @@ -47,13 +84,92 @@ class AudioDecimatorState { void prepare(UINT32 factor, UINT32 channels); bool consume(const double* frame, double* out); + // --- Internal: the cross-packet interpolation state (issue #920 item 24) --- + // Not part of the caller's contract. `prepareInterpolation` continues the + // active stream when the format pair matches and cold-starts otherwise + // (clearing integer history on the way in); it returns false only when the + // state refuses input — a finalized stream, or checked counter arithmetic + // that overflowed (fail-safe: no output until reset()). + bool prepareInterpolation(const AudioInputFormat& source, const AudioInputFormat& target); + // Called by the integer decimation branch so a state that was interpolating + // begins decimation cold exactly once per transition. + void beginIntegerDecimation(); + // Appends one source frame, already channel-mapped to `channels` target + // channels and UNSCALED (gain is applied at the write, not here). + void appendInterpolationFrame(const double* mappedFrame, UINT32 channels); + + bool interpolationActive() const { return mode_ == Mode::kInterpolation; } + bool interpolationFinalized() const { return interpolationFinalized_; } + uint64_t interpolationInputFrames() const { return interpolationInputFrames_; } + uint64_t interpolationEmittedFrames() const { return interpolationEmittedFrames_; } + // K(N): the stream's final output quota, max(1, round_half_up(N*T/S)). + uint64_t interpolationTotalFrames() const { return interpolationTotalFrames_; } + // C(N): frames emittable before finalization (== K(N) when S == T). + uint64_t interpolationEmittableFrames() const { return interpolationEmittableFrames_; } + // K(N) - C(N): the deferred-output count, bounded by ceil(T/S + 1/2). + uint64_t interpolationPendingFrames() const { + return interpolationTotalFrames_ >= interpolationEmittableFrames_ + ? interpolationTotalFrames_ - interpolationEmittableFrames_ + : 0; + } + size_t interpolationRetainedFrames() const; + double interpolationRateRatio() const { + return static_cast(interpolationTargetRate_) / + static_cast(interpolationSourceRate_); + } + double interpolationRetainedSample(uint64_t absoluteFrame, UINT32 channel) const; + void noteInterpolationEmitted(uint64_t throughFrameCount); + void trimInterpolationHistory(); + void markInterpolationFinalized(); + // Mixer-boundary discontinuity hook (a starved source resuming): abandons + // ONLY the cross-packet interpolation stream — retained history, quotas, + // flags and recorded formats — so no later output frame can interpolate + // across the real-time gap. Integer decimation state and every + // non-interpolating state are untouched (no-op); the next conversion + // cold-starts the stream, exactly as a fresh state would. That cold start + // is deliberate even for a finalized or overflow-parked stream: the + // discontinuity revives it fresh (production mixers never finalize, and + // counter overflow is unreachable in practice, so this only matters to + // state-machine completeness). + void abandonInterpolation(); + private: + enum class Mode { + kNone, + kInteger, + kInterpolation, + }; + std::vector taps_; std::vector history_; size_t position_ = 0; size_t phase_ = 0; UINT32 factor_ = 0; UINT32 channels_ = 0; + + Mode mode_ = Mode::kNone; + bool interpolationFinalized_ = false; + // Set when checked counter arithmetic overflows; the state then refuses + // input (empty output) until reset() rather than wrapping the geometry. + bool interpolationOverflow_ = false; + GUID interpolationSourceSubtype_{}; + GUID interpolationTargetSubtype_{}; + UINT32 interpolationSourceRate_ = 0; + UINT32 interpolationTargetRate_ = 0; + UINT32 interpolationSourceChannels_ = 0; + UINT32 interpolationTargetChannels_ = 0; + UINT32 interpolationSourceBits_ = 0; + UINT32 interpolationTargetBits_ = 0; + // Cumulative input frames N, output frames emitted, and the quotas K(N) + // and C(N) for the current stream prefix. + uint64_t interpolationInputFrames_ = 0; + uint64_t interpolationEmittedFrames_ = 0; + uint64_t interpolationTotalFrames_ = 0; + uint64_t interpolationEmittableFrames_ = 0; + // Absolute source-frame index of retained_[0]; the retained frames are + // channel-mapped, unscaled doubles, bounded per the class comment. + uint64_t interpolationHistoryBase_ = 0; + std::vector interpolationRetained_; }; void convertAudioWithGain( @@ -64,6 +180,32 @@ void convertAudioWithGain( double gain, std::vector& destination, AudioDecimatorState& decimator); +// Emits the deferred tail of a non-integer conversion stream: the output +// frames between what convertAudioWithGain has already emitted and the +// stream's globally rounded quota K(N), computed with the same whole-stream +// endpoint clamping and interpolation arithmetic the pre-fix one-shot +// conversion used. The bytes are APPENDED to `destination`; finalization +// never re-emits earlier output. +// +// Contract: +// * Repeated finalization returns no additional bytes. +// * Finalizing an empty stream returns no bytes. +// * A finalized stream accepts no more conversion input until reset() or a +// fresh state (convertAudioWithGain refuses with empty output). +// * No-op for integer and same-format conversion; it must not alter their +// remainder/history behavior. +// * Retained samples stay unscaled; `gain` is applied at the write, and the +// target format must be the one the stream was converted to. +// * Precondition: `destination` must be empty or contain a whole number of +// `targetFormat` frames — its size must be a multiple of +// `targetFormat.blockAlign`. The tail is appended after the last whole +// frame with frame-aligned typed stores; a caller-supplied trailing +// partial frame is outside this contract and is not preserved. +void finalizeAudioConversion( + const AudioInputFormat& targetFormat, + double gain, + std::vector& destination, + AudioDecimatorState& decimator); // Mixes `source` into `destination`, scaling the source by `sourceGain` first. // The gain rides in the double-domain sum and the result is clamped exactly // once, at the write: a source whose scaled samples exceed full scale must @@ -143,6 +285,9 @@ class AudioMixer { // placed at real time (see append). Written by mixLoop under `mutex_`. std::chrono::steady_clock::time_point clockStart_{}; bool clockAnchored_ = false; + // Test-only line of sight into the queues (defined in the test translation + // unit); adds no production queue API. + friend struct AudioMixerTestAccess; // Frames mixLoop has taken from the queues so far, under `mutex_`. uint64_t mixedFrames_ = 0; uint64_t emittedFrames_ = 0; diff --git a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp index 73995bd46..3654e5a78 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp @@ -20,6 +20,30 @@ #include #include +#ifndef OS920_PRISTINE_REGRESSION +// Test-only line of sight into AudioMixer's queues, via the narrow friendship +// declared in the fixed header (which names ::AudioMixerTestAccess, hence the +// global scope). Defined only in this translation unit; no production API. +struct AudioMixerTestAccess { + static const std::vector& systemQueue(const AudioMixer& mixer) { + return mixer.systemQueue_; + } + static const std::vector& microphoneQueue(const AudioMixer& mixer) { + return mixer.microphoneQueue_; + } + // P2 starvation-resume tests: put the mixer into exactly the state pop() + // leaves it in when a source queue runs dry in production, without + // driving the threaded mixLoop. Read path only for the queues; these + // setters are the one deliberate state injection. + static void setSystemStarved(AudioMixer& mixer, bool starved) { + mixer.systemStarved_ = starved; + } + static void setMicrophoneStarved(AudioMixer& mixer, bool starved) { + mixer.microphoneStarved_ = starved; + } +}; +#endif // !OS920_PRISTINE_REGRESSION + namespace { int g_ran = 0; @@ -213,9 +237,1352 @@ HRESULT trySetAacPcmRateWithVideo(UINT32 sampleRate) { return hr; } +// --- issue #920, item 24: packet-boundary continuity of non-integer resampling +// +// The non-integer branch of convertAudioWithGain used to reset its state and +// restart its phase at every packet, so the same signal produced different +// output depending on where the packet boundaries fell (14720 vs 14700 frames +// for a 16000-frame 48 kHz -> 44.1 kHz stream in 100-frame packets). These +// tests pin the fixed contract: +// +// * identical input prefixes produce identical output prefixes regardless +// of the partition, and a finalized stream equals the one-shot conversion +// byte for byte at the globally rounded frame count; +// * exact-multiple geometry (160/480-frame packets at 48 kHz -> 44.1 kHz) +// keeps its old bytes: packetized output equals both the one-shot +// conversion and a test-only reference that replicates the pre-fix +// whole-stream arithmetic; +// * the deferral ahead of finalization is bounded (<= 2 output frames for +// 48 kHz <-> 44.1 kHz; the general bound is ceil(T/S + 1/2)); +// * same-rate cross-subtype conversion (the production microphone shape) +// emits every frame immediately, so per-packet output counts match the old +// converter and nothing is held back; +// * the mixer keeps independent per-stream resampler state across +// pause/resume. +// +// Under OS920_PRISTINE_REGRESSION this file is compiled beside the PRISTINE +// (pre-fix) header and implementation: everything that needs the fixed header +// -- finalizeAudioConversion, the interpolation-state accessors and the +// AudioMixerTestAccess mixer tests -- is compiled out, and what remains fails +// the old code by assertion (build exit 0, test exit 1) while the exact- +// multiple (B/C) and same-rate controls still pass on it. + +std::vector monoFloatSine(UINT32 rate, double freq, double amplitude, size_t frames) { + std::vector bytes(frames * 4, 0); + auto* samples = reinterpret_cast(bytes.data()); + for (size_t i = 0; i < frames; i += 1) { + samples[i] = static_cast( + amplitude * std::sin(2.0 * kTestPi * freq * static_cast(i) / rate)); + } + return bytes; +} + +// A different frequency per channel, so a channel-mapping or gain-order +// regression cannot hide behind identical channels. +std::vector stereoFloatDualSine( + UINT32 rate, double leftHz, double rightHz, double amplitude, size_t frames) { + std::vector bytes(frames * 8, 0); + auto* samples = reinterpret_cast(bytes.data()); + for (size_t i = 0; i < frames; i += 1) { + const double t = static_cast(i) / rate; + samples[i * 2] = static_cast(amplitude * std::sin(2.0 * kTestPi * leftHz * t)); + samples[i * 2 + 1] = static_cast(amplitude * std::sin(2.0 * kTestPi * rightHz * t)); + } + return bytes; +} + +// Abrupt level changes and zero stretches: a smooth sine lets a boundary +// error hide inside interpolation error, an abrupt change cannot. +std::vector monoFloatAbrupt(UINT32 rate, size_t frames) { + std::vector bytes(frames * 4, 0); + auto* samples = reinterpret_cast(bytes.data()); + for (size_t i = 0; i < frames; i += 1) { + const size_t phase = i % 1000; + double v = 0.0; + if (phase < 250) { + v = 0.9; + } else if (phase < 500) { + v = 0.0; + } else if (phase < 750) { + v = -0.8; + } else { + v = 0.4 * std::sin(2.0 * kTestPi * 3000.0 * static_cast(i) / rate); + } + samples[i] = static_cast(v); + } + return bytes; +} + +// Test-only reference: the PRE-FIX whole-stream (one-shot) conversion, +// replicated here so expected bytes come from the documented old arithmetic -- +// per-stream llround frame count, endpoint clamp at the last source frame, +// linear interpolation with the old operation order, the old PCM16/float +// quantization and clamping -- and never from the converter under test. +// Sources are float32 (mono or stereo); targets PCM16 or float32, mono or +// stereo. +namespace legacy { + +double readSample(const BYTE* src, const AudioInputFormat& f, size_t frame, UINT32 ch) { + const size_t offset = frame * f.blockAlign + ch * (f.bitsPerSample / 8); + return static_cast(*reinterpret_cast(src + offset)); +} + +double mappedChannel( + const BYTE* src, const AudioInputFormat& f, size_t frame, UINT32 targetCh, UINT32 targetChannels) { + if (f.channels == 0) { + return 0.0; + } + if (f.channels == targetChannels && targetCh < f.channels) { + return readSample(src, f, frame, targetCh); + } + if (f.channels == 1) { + return readSample(src, f, frame, 0); + } + if (targetChannels == 1) { + double sum = 0.0; + for (UINT32 ch = 0; ch < f.channels; ++ch) { + sum += readSample(src, f, frame, ch); + } + return sum / f.channels; + } + return readSample(src, f, frame, std::min(targetCh, f.channels - 1)); +} + +void writeSample(BYTE* dst, const AudioInputFormat& f, size_t frame, UINT32 ch, double value) { + const double clamped = std::clamp(value, -1.0, 1.0); + const size_t offset = frame * f.blockAlign + ch * (f.bitsPerSample / 8); + if (f.subtype == MFAudioFormat_Float && f.bitsPerSample == 32) { + *reinterpret_cast(dst + offset) = static_cast(clamped); + return; + } + *reinterpret_cast(dst + offset) = static_cast( + std::clamp(std::round(clamped * 32767.0), -32768.0, 32767.0)); +} + +std::vector oneShot( + const std::vector& input, + const AudioInputFormat& src, + const AudioInputFormat& dst, + double gain) { + const size_t sourceFrames = input.size() / src.blockAlign; + if (sourceFrames == 0) { + return {}; + } + const double rateRatio = static_cast(dst.sampleRate) / static_cast(src.sampleRate); + const size_t targetFrames = std::max( + 1, static_cast(std::llround(static_cast(sourceFrames) * rateRatio))); + std::vector out(targetFrames * dst.blockAlign, 0); + for (size_t j = 0; j < targetFrames; ++j) { + const double sourcePosition = static_cast(j) / rateRatio; + const size_t sourceFrame = std::min(sourceFrames - 1, static_cast(sourcePosition)); + const size_t nextFrame = std::min(sourceFrames - 1, sourceFrame + 1); + const double frac = sourcePosition - static_cast(sourceFrame); + for (UINT32 ch = 0; ch < dst.channels; ++ch) { + const double a = mappedChannel(input.data(), src, sourceFrame, ch, dst.channels); + const double b = mappedChannel(input.data(), src, nextFrame, ch, dst.channels); + writeSample(out.data(), dst, j, ch, (a + (b - a) * frac) * gain); + } + } + return out; +} + +} // namespace legacy + +// Expected global counts, computed test-side with plain 64-bit integer +// arithmetic (fixture magnitudes sit far below any overflow): +// K(N) = max(1, round_half_up(N*T/S)) and C(N) = min(K(N), ceil((N-1)*T/S)), +// with C(N) = K(N) when the rates are equal (immediate emission). These are +// the documented quota definitions, applied independently of the converter. +uint64_t ratioRoundHalfUp(uint64_t n, uint64_t t, uint64_t s) { + return (2 * n * t + s) / (2 * s); +} + +uint64_t ratioCeil(uint64_t m, uint64_t t, uint64_t s) { + return (m * t + s - 1) / s; +} + +uint64_t expectedQuota(uint64_t inputFrames, UINT32 sourceRate, UINT32 targetRate) { + const uint64_t k = ratioRoundHalfUp(inputFrames, targetRate, sourceRate); + return k < 1 ? 1 : k; +} + +uint64_t expectedEmittable(uint64_t inputFrames, UINT32 sourceRate, UINT32 targetRate) { + const uint64_t k = expectedQuota(inputFrames, sourceRate, targetRate); + if (sourceRate == targetRate) { + return k; + } + const uint64_t c = ratioCeil(inputFrames - 1, targetRate, sourceRate); + return c < k ? c : k; +} + +// ceil(T/S + 1/2): the general deferral bound for a rate pair. +size_t pendingBoundFor(UINT32 sourceRate, UINT32 targetRate) { + return static_cast((2 * uint64_t(targetRate) + sourceRate + 2 * uint64_t(sourceRate) - 1) / + (2 * uint64_t(sourceRate))); +} + +// ceil(S/(2T)) + 2: the conservative retained-history bound once a packet's +// emission has settled (one frame of the allowance is for the legacy +// double-precision index calculation). +size_t retainedBoundFor(UINT32 sourceRate, UINT32 targetRate) { + return static_cast((uint64_t(sourceRate) + 2 * targetRate - 1) / (2 * uint64_t(targetRate))) + 2; +} + +struct PartitionRun { + std::vector output; + std::vector perPacketFrames; +}; + +// Feeds `input` through the STATEFUL converter in packets whose sizes cycle +// through `sizes` (the last packet may be partial). With finalizeAtEnd the +// deferred tail is flushed through finalizeAudioConversion (fixed builds +// only; the pristine build has no finalizer and its packetized output is +// already its full output). +PartitionRun runPartitioned( + const std::vector& input, + const AudioInputFormat& src, + const AudioInputFormat& dst, + double gain, + const std::vector& sizes, + size_t totalFrames, + bool finalizeAtEnd) { + PartitionRun run; + AudioDecimatorState state; + std::vector piece; + size_t cursor = 0; + size_t index = 0; + while (cursor < totalFrames) { + const size_t frames = std::min(sizes[index % sizes.size()], totalFrames - cursor); + index += 1; + convertAudioWithGain( + input.data() + cursor * src.blockAlign, + static_cast(frames * src.blockAlign), + src, + dst, + gain, + piece, + state); + run.perPacketFrames.push_back(dst.blockAlign == 0 ? 0 : piece.size() / dst.blockAlign); + run.output.insert(run.output.end(), piece.begin(), piece.end()); + cursor += frames; + } +#ifndef OS920_PRISTINE_REGRESSION + if (finalizeAtEnd) { + piece.clear(); + finalizeAudioConversion(dst, gain, piece, state); + if (!piece.empty()) { + run.perPacketFrames.back() += dst.blockAlign == 0 ? 0 : piece.size() / dst.blockAlign; + run.output.insert(run.output.end(), piece.begin(), piece.end()); + } + } +#else + (void)finalizeAtEnd; +#endif + return run; +} + +bool isBytePrefix(const std::vector& prefix, const std::vector& whole) { + return prefix.size() <= whole.size() && + std::equal(prefix.begin(), prefix.end(), whole.begin()); +} + +std::string frameCountOf(const std::vector& bytes, const AudioInputFormat& f) { + return std::to_string(f.blockAlign == 0 ? 0 : bytes.size() / f.blockAlign); +} + +void runResampleContinuityCoreTests() { + const AudioInputFormat src48f = makeFormat(MFAudioFormat_Float, 48000, 1, 32); + const AudioInputFormat dst44p = makeFormat(MFAudioFormat_PCM, 44100, 1, 16); + const AudioInputFormat src44f = makeFormat(MFAudioFormat_Float, 44100, 1, 32); + const AudioInputFormat dst48p = makeFormat(MFAudioFormat_PCM, 48000, 1, 16); + const std::vector raggedSizes = {1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 100, 159, 160, 480}; + + const std::vector signalA = monoFloatSine(48000, 997.0, 0.5, 16000); + const std::vector signalD = monoFloatSine(44100, 997.0, 0.5, 14700); + std::vector oneA; + convertAudioWithGain( + signalA.data(), static_cast(signalA.size()), src48f, dst44p, 1.0, oneA); + std::vector oneD; + convertAudioWithGain( + signalD.data(), static_cast(signalD.size()), src44f, dst48p, 1.0, oneD); + + // The one-shot conversions must keep their old bytes, checked against the + // test-only legacy reference -- never against a packetized path. + expect( + "os920-a-one-shot-matches-legacy-reference", + oneA == legacy::oneShot(signalA, src48f, dst44p, 1.0), + "frames=" + frameCountOf(oneA, dst44p)); + expect( + "os920-d-one-shot-matches-legacy-reference", + oneD == legacy::oneShot(signalD, src44f, dst48p, 1.0), + "frames=" + frameCountOf(oneD, dst48p)); + + // A: 48 kHz -> 44.1 kHz in 100-frame packets (91.875 expected frames per + // packet; the old per-packet llround made 92, +0.125 frames/packet). + { + const PartitionRun run = runPartitioned(signalA, src48f, dst44p, 1.0, {100}, 16000, false); + expect( + "os920-a-packetized-prefix-matches-one-shot", + isBytePrefix(run.output, oneA), + "frames=" + frameCountOf(run.output, dst44p) + " of " + frameCountOf(oneA, dst44p)); + expect( + "os920-a-packetized-frame-count", + run.output.size() / dst44p.blockAlign == 14700, + "frames=" + frameCountOf(run.output, dst44p)); + } + + // Controls B/C: exact-multiple geometry keeps the old bytes, packetized + // equal to one-shot AND to the legacy reference. + { + const PartitionRun b = runPartitioned(signalA, src48f, dst44p, 1.0, {160}, 16000, false); + expect( + "os920-b-160-frame-packets-equal-one-shot", + b.output == oneA, + "frames=" + frameCountOf(b.output, dst44p)); + expect( + "os920-b-160-frame-packets-match-legacy-reference", + b.output == legacy::oneShot(signalA, src48f, dst44p, 1.0), + "frames=" + frameCountOf(b.output, dst44p)); + const PartitionRun c = runPartitioned(signalA, src48f, dst44p, 1.0, {480}, 16000, false); + expect( + "os920-c-480-frame-packets-equal-one-shot", + c.output == oneA, + "frames=" + frameCountOf(c.output, dst44p)); + expect( + "os920-c-480-frame-packets-match-legacy-reference", + c.output == legacy::oneShot(signalA, src48f, dst44p, 1.0), + "frames=" + frameCountOf(c.output, dst44p)); + } + + // D: 44.1 kHz -> 48 kHz upsample in 100-frame packets (108.844 expected + // frames per packet; the old rounding error here is ~0.1565 frames/packet, + // a different geometry from A's). + { + const PartitionRun d = runPartitioned(signalD, src44f, dst48p, 1.0, {100}, 14700, false); + expect( + "os920-d-packetized-prefix-matches-one-shot", + isBytePrefix(d.output, oneD), + "frames=" + frameCountOf(d.output, dst48p) + " of " + frameCountOf(oneD, dst48p)); + } + + // Ragged streams in both directions over a deliberately non-ratio-aligned + // total, with abrupt changes and zero stretches so boundary errors cannot + // hide in a smooth sine. + { + const std::vector rag48 = monoFloatAbrupt(48000, 7777); + const std::vector rag44 = monoFloatAbrupt(44100, 7777); + std::vector oneR48; + convertAudioWithGain( + rag48.data(), static_cast(rag48.size()), src48f, dst44p, 1.0, oneR48); + std::vector oneR44; + convertAudioWithGain( + rag44.data(), static_cast(rag44.size()), src44f, dst48p, 1.0, oneR44); + const PartitionRun r1 = runPartitioned(rag48, src48f, dst44p, 1.0, raggedSizes, 7777, false); + expect( + "os920-ragged-48-to-44-prefix-matches-one-shot", + isBytePrefix(r1.output, oneR48), + "frames=" + frameCountOf(r1.output, dst44p) + " of " + frameCountOf(oneR48, dst44p)); + const PartitionRun r2 = runPartitioned(rag44, src44f, dst48p, 1.0, raggedSizes, 7777, false); + expect( + "os920-ragged-44-to-48-prefix-matches-one-shot", + isBytePrefix(r2.output, oneR44), + "frames=" + frameCountOf(r2.output, dst48p) + " of " + frameCountOf(oneR44, dst48p)); + } + + // Rounding and endpoint totals: one-frame packets across totals that + // bracket the rounding boundaries. The old per-packet minimum of one frame + // accumulated (80 one-frame packets at 48 kHz -> 44.1 kHz produced 80 + // frames for a 74-frame stream); the fixed converter owes only the global + // quota, and every emitted frame must sit at its absolute stream position. + { + const size_t totals[] = {1, 2, 3, 79, 80, 81, 99, 100, 101, 159, 160, 161}; + const std::vector master48 = monoFloatSine(48000, 997.0, 0.5, 161); + const std::vector master44 = monoFloatSine(44100, 997.0, 0.5, 161); + bool ok48 = true; + bool ok44 = true; + std::string detail48; + std::string detail44; + for (size_t n : totals) { + std::vector in48( + master48.begin(), master48.begin() + static_cast(n) * src48f.blockAlign); + std::vector one; + convertAudioWithGain( + in48.data(), static_cast(in48.size()), src48f, dst44p, 1.0, one); + const PartitionRun run = runPartitioned(in48, src48f, dst44p, 1.0, {1}, n, false); + if (!isBytePrefix(run.output, one)) { + ok48 = false; + detail48 += "n" + std::to_string(n) + ":" + frameCountOf(run.output, dst44p) + + "/" + frameCountOf(one, dst44p) + " "; + } + std::vector in44( + master44.begin(), master44.begin() + static_cast(n) * src44f.blockAlign); + std::vector oneUp; + convertAudioWithGain( + in44.data(), static_cast(in44.size()), src44f, dst48p, 1.0, oneUp); + const PartitionRun runUp = runPartitioned(in44, src44f, dst48p, 1.0, {1}, n, false); + if (!isBytePrefix(runUp.output, oneUp)) { + ok44 = false; + detail44 += "n" + std::to_string(n) + ":" + frameCountOf(runUp.output, dst48p) + + "/" + frameCountOf(oneUp, dst48p) + " "; + } + } + expect("os920-rounding-totals-1-frame-packets-prefix-48-to-44", ok48, detail48); + expect("os920-rounding-totals-1-frame-packets-prefix-44-to-48", ok44, detail44); + } + + // Formats and gain: distinct-channel float32 input, PCM16 and float32 + // targets, fixed gains 1.0 and 0.5, stereo->stereo, stereo->mono downmix + // and mono->stereo upmix, all through ragged packets of a non-integer + // ratio. Partition equality must hold without altered mapping, gain order + // or clipping. + { + const AudioInputFormat src44stereo = makeFormat(MFAudioFormat_Float, 44100, 2, 32); + const std::vector stereo = stereoFloatDualSine(44100, 997.0, 501.0, 0.5, 3000); + struct FormatCase { + const char* name; + AudioInputFormat dst; + double gain; + }; + const FormatCase cases[] = { + {"pcm16-stereo", makeFormat(MFAudioFormat_PCM, 48000, 2, 16), 1.0}, + {"pcm16-stereo-half-gain", makeFormat(MFAudioFormat_PCM, 48000, 2, 16), 0.5}, + {"float32-stereo", makeFormat(MFAudioFormat_Float, 48000, 2, 32), 1.0}, + {"pcm16-mono-downmix", makeFormat(MFAudioFormat_PCM, 48000, 1, 16), 1.0}, + {"pcm16-mono-downmix-half-gain", makeFormat(MFAudioFormat_PCM, 48000, 1, 16), 0.5}, + }; + bool allPrefix = true; + bool allRef = true; + std::string detail; + for (const FormatCase& c : cases) { + std::vector one; + convertAudioWithGain( + stereo.data(), static_cast(stereo.size()), src44stereo, c.dst, c.gain, one); + const PartitionRun run = + runPartitioned(stereo, src44stereo, c.dst, c.gain, raggedSizes, 3000, false); + if (!isBytePrefix(run.output, one)) { + allPrefix = false; + detail += std::string(c.name) + ":" + frameCountOf(run.output, c.dst) + "/" + + frameCountOf(one, c.dst) + " "; + } + if (one != legacy::oneShot(stereo, src44stereo, c.dst, c.gain)) { + allRef = false; + detail += std::string(c.name) + ":ref "; + } + } + expect("os920-formats-gain-ragged-prefix-matches-one-shot", allPrefix, detail); + expect("os920-formats-gain-one-shot-matches-legacy-reference", allRef, detail); + + const std::vector mono = monoFloatSine(44100, 777.0, 0.5, 2500); + const AudioInputFormat dstStereoUp = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + std::vector oneUp; + convertAudioWithGain( + mono.data(), static_cast(mono.size()), src44f, dstStereoUp, 1.0, oneUp); + const PartitionRun runUp = + runPartitioned(mono, src44f, dstStereoUp, 1.0, raggedSizes, 2500, false); + expect( + "os920-mono-to-stereo-ragged-prefix-matches-one-shot", + isBytePrefix(runUp.output, oneUp), + "frames=" + frameCountOf(runUp.output, dstStereoUp) + " of " + + frameCountOf(oneUp, dstStereoUp)); + expect( + "os920-mono-to-stereo-one-shot-matches-legacy-reference", + oneUp == legacy::oneShot(mono, src44f, dstStereoUp, 1.0), + "frames=" + frameCountOf(oneUp, dstStereoUp)); + } + + // Same-rate cross-subtype conversion (the production microphone shape: + // WASAPI float32 against a PCM16 target fails sameAudioFormatForMixing on + // subtype alone). The packetized stream equals the one-shot conversion + // byte for byte -- on the fixed build because same-rate frames emit + // immediately, and on the old build because each packet's local phase + // restart is exact here (frac == 0). Per-packet counts must equal the old + // converter's own (480 frames out per 480 in, on both builds). + { + const AudioInputFormat src48stereo = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + const AudioInputFormat dst48pcm = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const std::vector mic = stereoFloatDualSine(48000, 997.0, 503.0, 0.5, 5000); + std::vector one; + convertAudioWithGain( + mic.data(), static_cast(mic.size()), src48stereo, dst48pcm, 1.0, one); + const PartitionRun rag = + runPartitioned(mic, src48stereo, dst48pcm, 1.0, raggedSizes, 5000, false); + expect( + "os920-same-rate-packetized-equals-one-shot", + rag.output == one, + "frames=" + frameCountOf(rag.output, dst48pcm)); + expect( + "os920-same-rate-one-shot-matches-legacy-reference", + one == legacy::oneShot(mic, src48stereo, dst48pcm, 1.0), + "frames=" + frameCountOf(one, dst48pcm)); + const PartitionRun p480 = runPartitioned(mic, src48stereo, dst48pcm, 1.0, {480}, 5000, false); + bool countsOk = p480.perPacketFrames.size() == 11; + for (size_t i = 0; countsOk && i < p480.perPacketFrames.size(); i += 1) { + countsOk = p480.perPacketFrames[i] == (i < 10 ? 480 : 200); + } + expect( + "os920-same-rate-per-packet-counts-match-old-converter", + countsOk, + "packets=" + std::to_string(p480.perPacketFrames.size())); + } +} + +#ifndef OS920_PRISTINE_REGRESSION +void runResampleContinuityFixedTests() { + const AudioInputFormat src48f = makeFormat(MFAudioFormat_Float, 48000, 1, 32); + const AudioInputFormat dst44p = makeFormat(MFAudioFormat_PCM, 44100, 1, 16); + const AudioInputFormat src44f = makeFormat(MFAudioFormat_Float, 44100, 1, 32); + const AudioInputFormat dst48p = makeFormat(MFAudioFormat_PCM, 48000, 1, 16); + const std::vector raggedSizes = {1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 100, 159, 160, 480}; + + const std::vector signalA = monoFloatSine(48000, 997.0, 0.5, 16000); + const std::vector signalD = monoFloatSine(44100, 997.0, 0.5, 14700); + std::vector oneA; + convertAudioWithGain( + signalA.data(), static_cast(signalA.size()), src48f, dst44p, 1.0, oneA); + std::vector oneD; + convertAudioWithGain( + signalD.data(), static_cast(signalD.size()), src44f, dst48p, 1.0, oneD); + + // A: intermediate-prefix accounting and the bounds after EVERY packet, not + // just at stream end; then byte equality with the one-shot conversion + // (case A has no tail: C(16000) == K(16000) == 14700). + { + const UINT32 S = 48000; + const UINT32 T = 44100; + const size_t pendingBound = pendingBoundFor(S, T); + const size_t retainedBound = retainedBoundFor(S, T); + AudioDecimatorState state; + std::vector piece; + std::vector all; + size_t cumulative = 0; + bool okCounts = true; + bool okPending = true; + bool okRetained = true; + uint64_t worstPending = 0; + std::string detail; + for (size_t packet = 0; packet < 160; packet += 1) { + convertAudioWithGain( + signalA.data() + packet * 100 * src48f.blockAlign, + static_cast(100 * src48f.blockAlign), + src48f, + dst44p, + 1.0, + piece, + state); + cumulative += piece.size() / dst44p.blockAlign; + all.insert(all.end(), piece.begin(), piece.end()); + const uint64_t n = static_cast(packet + 1) * 100; + const uint64_t wantC = expectedEmittable(n, S, T); + if (cumulative != wantC) { + okCounts = false; + detail += "n" + std::to_string(n) + " got " + std::to_string(cumulative) + " want " + + std::to_string(wantC) + " "; + } + worstPending = std::max(worstPending, state.interpolationPendingFrames()); + okPending = okPending && state.interpolationPendingFrames() <= pendingBound; + okRetained = okRetained && state.interpolationRetainedFrames() <= retainedBound; + } + detail += "worstPending=" + std::to_string(worstPending); + expect("os920-a-intermediate-prefix-accounting", okCounts, detail); + expect("os920-a-pending-output-bound", okPending, detail); + expect("os920-a-retained-history-bound", okRetained, detail); + std::vector tail; + finalizeAudioConversion(dst44p, 1.0, tail, state); + all.insert(all.end(), tail.begin(), tail.end()); + expect( + "os920-a-unfinalized-14700-and-finalize-empty-equals-one-shot", + cumulative == 14700 && tail.empty() && all == oneA, + "cumulative=" + std::to_string(cumulative) + " tail=" + std::to_string(tail.size())); + } + + // D: the same walk; the documented 15999-frame unfinalized prefix and the + // 16000-frame finalized equality, plus the finalization lifecycle. + { + const UINT32 S = 44100; + const UINT32 T = 48000; + const size_t pendingBound = pendingBoundFor(S, T); + const size_t retainedBound = retainedBoundFor(S, T); + AudioDecimatorState state; + std::vector piece; + std::vector all; + size_t cumulative = 0; + bool okCounts = true; + bool okPending = true; + bool okRetained = true; + std::string detail; + for (size_t packet = 0; packet < 147; packet += 1) { + convertAudioWithGain( + signalD.data() + packet * 100 * src44f.blockAlign, + static_cast(100 * src44f.blockAlign), + src44f, + dst48p, + 1.0, + piece, + state); + cumulative += piece.size() / dst48p.blockAlign; + all.insert(all.end(), piece.begin(), piece.end()); + const uint64_t n = static_cast(packet + 1) * 100; + const uint64_t wantC = expectedEmittable(n, S, T); + if (cumulative != wantC) { + okCounts = false; + detail += "n" + std::to_string(n) + " got " + std::to_string(cumulative) + " want " + + std::to_string(wantC) + " "; + } + okPending = okPending && state.interpolationPendingFrames() <= pendingBound; + okRetained = okRetained && state.interpolationRetainedFrames() <= retainedBound; + } + expect("os920-d-intermediate-prefix-accounting", okCounts, detail); + expect("os920-d-pending-output-bound", okPending, detail); + expect("os920-d-retained-history-bound", okRetained, detail); + expect( + "os920-d-unfinalized-prefix-is-15999", + cumulative == 15999, + "cumulative=" + std::to_string(cumulative)); + piece.clear(); + finalizeAudioConversion(dst48p, 1.0, piece, state); + all.insert(all.end(), piece.begin(), piece.end()); + expect( + "os920-d-finalized-equals-one-shot-16000", + piece.size() / dst48p.blockAlign == 1 && all.size() / dst48p.blockAlign == 16000 && + all == oneD, + "tail=" + std::to_string(piece.size() / dst48p.blockAlign) + " total=" + + std::to_string(all.size() / dst48p.blockAlign)); + // Repeated finalization returns no additional bytes. + std::vector again; + finalizeAudioConversion(dst48p, 1.0, again, state); + expect("os920-d-repeat-finalize-is-empty", again.empty(), "bytes=" + std::to_string(again.size())); + // A finalized stream refuses more input until reset() (or a fresh state). + std::vector more; + convertAudioWithGain( + signalD.data(), static_cast(300 * src44f.blockAlign), src44f, dst48p, 1.0, more, + state); + expect( + "os920-d-finalized-refuses-input", + more.empty(), + "bytes=" + std::to_string(more.size())); + // reset() re-enables a fresh stream, equal to a cold state's conversion + // of the same packets. + state.reset(); + std::vector afterReset; + size_t cursor = 0; + for (int i = 0; i < 3; i += 1) { + convertAudioWithGain( + signalD.data() + cursor * src44f.blockAlign, + static_cast(100 * src44f.blockAlign), + src44f, + dst48p, + 1.0, + piece, + state); + afterReset.insert(afterReset.end(), piece.begin(), piece.end()); + cursor += 100; + } + AudioDecimatorState cold; + std::vector fromCold; + cursor = 0; + for (int i = 0; i < 3; i += 1) { + convertAudioWithGain( + signalD.data() + cursor * src44f.blockAlign, + static_cast(100 * src44f.blockAlign), + src44f, + dst48p, + 1.0, + piece, + cold); + fromCold.insert(fromCold.end(), piece.begin(), piece.end()); + cursor += 100; + } + expect( + "os920-d-reset-enables-fresh-stream", + afterReset == fromCold && !afterReset.empty(), + "reset=" + std::to_string(afterReset.size()) + " cold=" + std::to_string(fromCold.size())); + } + + // Finalized equality across the remaining matrix: ragged streams in both + // directions, all rounding totals with one-frame packets, and every + // formats/gain case. + { + const std::vector rag48 = monoFloatAbrupt(48000, 7777); + const std::vector rag44 = monoFloatAbrupt(44100, 7777); + std::vector oneR48; + convertAudioWithGain( + rag48.data(), static_cast(rag48.size()), src48f, dst44p, 1.0, oneR48); + std::vector oneR44; + convertAudioWithGain( + rag44.data(), static_cast(rag44.size()), src44f, dst48p, 1.0, oneR44); + const PartitionRun r1 = runPartitioned(rag48, src48f, dst44p, 1.0, raggedSizes, 7777, true); + expect( + "os920-ragged-48-to-44-finalized-equals-one-shot", + r1.output == oneR48 && + r1.output.size() / dst44p.blockAlign == expectedQuota(7777, 48000, 44100), + "frames=" + frameCountOf(r1.output, dst44p)); + const PartitionRun r2 = runPartitioned(rag44, src44f, dst48p, 1.0, raggedSizes, 7777, true); + expect( + "os920-ragged-44-to-48-finalized-equals-one-shot", + r2.output == oneR44 && + r2.output.size() / dst48p.blockAlign == expectedQuota(7777, 44100, 48000), + "frames=" + frameCountOf(r2.output, dst48p)); + + const size_t totals[] = {1, 2, 3, 79, 80, 81, 99, 100, 101, 159, 160, 161}; + const std::vector master48 = monoFloatSine(48000, 997.0, 0.5, 161); + const std::vector master44 = monoFloatSine(44100, 997.0, 0.5, 161); + bool ok48 = true; + bool ok44 = true; + std::string detail; + for (size_t n : totals) { + std::vector in48( + master48.begin(), master48.begin() + static_cast(n) * src48f.blockAlign); + std::vector one; + convertAudioWithGain( + in48.data(), static_cast(in48.size()), src48f, dst44p, 1.0, one); + const PartitionRun run = runPartitioned(in48, src48f, dst44p, 1.0, {1}, n, true); + if (run.output != one || run.output.size() / dst44p.blockAlign != expectedQuota(n, 48000, 44100)) { + ok48 = false; + detail += "n" + std::to_string(n) + ":" + frameCountOf(run.output, dst44p) + "/" + + frameCountOf(one, dst44p) + " "; + } + std::vector in44( + master44.begin(), master44.begin() + static_cast(n) * src44f.blockAlign); + std::vector oneUp; + convertAudioWithGain( + in44.data(), static_cast(in44.size()), src44f, dst48p, 1.0, oneUp); + const PartitionRun runUp = runPartitioned(in44, src44f, dst48p, 1.0, {1}, n, true); + if (runUp.output != oneUp || runUp.output.size() / dst48p.blockAlign != expectedQuota(n, 44100, 48000)) { + ok44 = false; + detail += "n" + std::to_string(n) + ":" + frameCountOf(runUp.output, dst48p) + "/" + + frameCountOf(oneUp, dst48p) + " "; + } + } + expect("os920-rounding-totals-finalized-equal-one-shot-48-to-44", ok48, detail); + expect("os920-rounding-totals-finalized-equal-one-shot-44-to-48", ok44, detail); + + const AudioInputFormat src44stereo = makeFormat(MFAudioFormat_Float, 44100, 2, 32); + const std::vector stereo = stereoFloatDualSine(44100, 997.0, 501.0, 0.5, 3000); + struct FormatCase { + const char* name; + AudioInputFormat dst; + double gain; + }; + const FormatCase cases[] = { + {"pcm16-stereo", makeFormat(MFAudioFormat_PCM, 48000, 2, 16), 1.0}, + {"pcm16-stereo-half-gain", makeFormat(MFAudioFormat_PCM, 48000, 2, 16), 0.5}, + {"float32-stereo", makeFormat(MFAudioFormat_Float, 48000, 2, 32), 1.0}, + {"pcm16-mono-downmix", makeFormat(MFAudioFormat_PCM, 48000, 1, 16), 1.0}, + {"pcm16-mono-downmix-half-gain", makeFormat(MFAudioFormat_PCM, 48000, 1, 16), 0.5}, + }; + bool allFinal = true; + std::string fmtDetail; + for (const FormatCase& c : cases) { + std::vector one; + convertAudioWithGain( + stereo.data(), static_cast(stereo.size()), src44stereo, c.dst, c.gain, one); + const PartitionRun run = + runPartitioned(stereo, src44stereo, c.dst, c.gain, raggedSizes, 3000, true); + if (run.output != one) { + allFinal = false; + fmtDetail += std::string(c.name) + " "; + } + } + expect("os920-formats-gain-finalized-equals-one-shot", allFinal, fmtDetail); + } + + // Generic deferral bound at a higher upsampling ratio (8 kHz -> 44.1 kHz): + // the bound is rate-geometric -- ceil(T/S + 1/2) = 7 here -- NOT a + // universal two frames. One-frame packets and ragged packets both. + { + const AudioInputFormat src8k = makeFormat(MFAudioFormat_Float, 8000, 1, 32); + const AudioInputFormat dst44k = makeFormat(MFAudioFormat_PCM, 44100, 1, 16); + const std::vector sig = monoFloatSine(8000, 997.0, 0.5, 4000); + const UINT32 S = 8000; + const UINT32 T = 44100; + const size_t pendingBound = pendingBoundFor(S, T); + const size_t retainedBound = retainedBoundFor(S, T); + bool okPending = pendingBound == 7; + bool okRetained = true; + std::string detail = "bound=" + std::to_string(pendingBound); + bool okFinal = true; + std::vector one8k; + convertAudioWithGain( + sig.data(), static_cast(sig.size()), src8k, dst44k, 1.0, one8k); + for (const std::vector& sizes : std::vector>{{size_t{1}}, raggedSizes}) { + AudioDecimatorState state; + std::vector piece; + std::vector all; + size_t cursor = 0; + size_t index = 0; + while (cursor < 4000) { + const size_t frames = std::min(sizes[index % sizes.size()], 4000 - cursor); + index += 1; + convertAudioWithGain( + sig.data() + cursor * src8k.blockAlign, + static_cast(frames * src8k.blockAlign), + src8k, + dst44k, + 1.0, + piece, + state); + all.insert(all.end(), piece.begin(), piece.end()); + cursor += frames; + okPending = okPending && state.interpolationPendingFrames() <= pendingBound; + okRetained = okRetained && state.interpolationRetainedFrames() <= retainedBound; + } + piece.clear(); + finalizeAudioConversion(dst44k, 1.0, piece, state); + all.insert(all.end(), piece.begin(), piece.end()); + okFinal = okFinal && all == one8k && + all.size() / dst44k.blockAlign == expectedQuota(4000, 8000, 44100); + } + expect("os920-generic-8k-to-44k-pending-bound-7", okPending, detail); + expect("os920-generic-8k-to-44k-retained-bound", okRetained, detail); + expect( + "os920-generic-8k-to-44k-finalized-equals-one-shot", + okFinal, + "frames=" + std::to_string(one8k.size() / dst44k.blockAlign)); + } + + // Same-rate (T == S) pin: same-rate + // cross-subtype conversion emits every frame immediately -- frac == 0 + // makes the clamped window exact -- so the held count is 0 (never above + // the <= 1 a uniform deferral would give), per-packet emission counts + // equal the old converter's, and finalization adds nothing. The held + // frame is NOT dropped at reset/resume/end-of-take because there is none. + { + const AudioInputFormat src48stereo = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + const AudioInputFormat dst48pcm = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const std::vector mic = stereoFloatDualSine(48000, 997.0, 503.0, 0.5, 5000); + std::vector one; + convertAudioWithGain( + mic.data(), static_cast(mic.size()), src48stereo, dst48pcm, 1.0, one); + const std::vector sizes = {479, 480, 1, 160, 3}; + AudioDecimatorState state; + std::vector piece; + std::vector all; + bool heldOk = true; + bool countsOk = true; + size_t cursor = 0; + size_t index = 0; + while (cursor < 5000) { + const size_t frames = std::min(sizes[index % sizes.size()], 5000 - cursor); + index += 1; + convertAudioWithGain( + mic.data() + cursor * src48stereo.blockAlign, + static_cast(frames * src48stereo.blockAlign), + src48stereo, + dst48pcm, + 1.0, + piece, + state); + countsOk = countsOk && piece.size() / dst48pcm.blockAlign == frames; + all.insert(all.end(), piece.begin(), piece.end()); + cursor += frames; + heldOk = heldOk && state.interpolationPendingFrames() == 0; + } + std::string detail = + "lastPacket=" + std::to_string(piece.size() / dst48pcm.blockAlign); + expect( + "os920-same-rate-held-count-never-exceeds-one", + heldOk, + detail + " (0 held: immediate emission; bound is <= 1)"); + expect("os920-same-rate-per-packet-emission-immediate", countsOk, detail); + piece.clear(); + finalizeAudioConversion(dst48pcm, 1.0, piece, state); + expect( + "os920-same-rate-finalize-adds-nothing", + piece.empty() && all == one, + "tail=" + std::to_string(piece.size()) + " frames=" + + std::to_string(all.size() / dst48pcm.blockAlign)); + } + + // Lifecycle: reset equals a cold start on the interpolation stream itself. + { + const std::vector warm = monoFloatAbrupt(48000, 777); + const std::vector tail = monoFloatAbrupt(48000, 1723); + AudioDecimatorState used; + std::vector scratch; + size_t cursor = 0; + size_t index = 0; + while (cursor < 777) { + const size_t frames = std::min(raggedSizes[index % raggedSizes.size()], 777 - cursor); + index += 1; + convertAudioWithGain( + warm.data() + cursor * src48f.blockAlign, + static_cast(frames * src48f.blockAlign), + src48f, + dst44p, + 1.0, + scratch, + used); + cursor += frames; + } + used.reset(); + std::vector afterReset; + cursor = 0; + index = 0; + while (cursor < 1723) { + const size_t frames = std::min(raggedSizes[index % raggedSizes.size()], 1723 - cursor); + index += 1; + convertAudioWithGain( + tail.data() + cursor * src48f.blockAlign, + static_cast(frames * src48f.blockAlign), + src48f, + dst44p, + 1.0, + scratch, + used); + afterReset.insert(afterReset.end(), scratch.begin(), scratch.end()); + cursor += frames; + } + AudioDecimatorState fresh; + std::vector fromCold; + cursor = 0; + index = 0; + while (cursor < 1723) { + const size_t frames = std::min(raggedSizes[index % raggedSizes.size()], 1723 - cursor); + index += 1; + convertAudioWithGain( + tail.data() + cursor * src48f.blockAlign, + static_cast(frames * src48f.blockAlign), + src48f, + dst44p, + 1.0, + scratch, + fresh); + fromCold.insert(fromCold.end(), scratch.begin(), scratch.end()); + cursor += frames; + } + expect( + "os920-interp-reset-equals-cold-start", + afterReset == fromCold && !afterReset.empty(), + "reset=" + std::to_string(afterReset.size()) + " cold=" + + std::to_string(fromCold.size())); + } + + // interpolation -> same-rate -> interpolation on ONE state: the cross-format + // contract keeps pendingFrames() at zero through the same-rate hop, and the + // interpolation stream restarts cold (equal to a fresh state). + { + const AudioInputFormat src48stereo = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + const AudioInputFormat dst48pcm = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const std::vector interpPart = monoFloatAbrupt(48000, 200); + const std::vector sameRatePart = stereoFloatDualSine(48000, 800.0, 300.0, 0.5, 100); + AudioDecimatorState reused; + std::vector scratch; + convertAudioWithGain( + interpPart.data(), static_cast(interpPart.size()), src48f, dst44p, 1.0, scratch, + reused); + const bool pendingBefore = reused.pendingFrames() == 0; + convertAudioWithGain( + sameRatePart.data(), static_cast(sameRatePart.size()), src48stereo, dst48pcm, + 1.0, scratch, reused); + const bool pendingAfterSameRate = reused.pendingFrames() == 0; + std::vector afterReturn; + convertAudioWithGain( + interpPart.data(), static_cast(interpPart.size()), src48f, dst44p, 1.0, + afterReturn, reused); + AudioDecimatorState fresh; + std::vector fromCold; + convertAudioWithGain( + interpPart.data(), static_cast(interpPart.size()), src48f, dst44p, 1.0, + fromCold, fresh); + expect( + "os920-interp-via-same-rate-restarts-cold", + pendingBefore && pendingAfterSameRate && afterReturn == fromCold && !afterReturn.empty(), + "before=" + std::to_string(pendingBefore) + " sameRate=" + + std::to_string(pendingAfterSameRate) + " match=" + + std::to_string(afterReturn == fromCold)); + } + + // interpolation -> integer: the integer filter starts cold and its + // remainder/history contract is intact after the transition. + { + const AudioInputFormat src96k = makeFormat(MFAudioFormat_PCM, 96000, 2, 16); + const AudioInputFormat dst48k = makeAacCompatibleAudioFormat(src96k); + const size_t integerFrames = 501; // odd: leaves exactly one frame pending + std::vector integerPacket(integerFrames * src96k.blockAlign, 0); + auto* samples = reinterpret_cast(integerPacket.data()); + for (size_t i = 0; i < integerFrames; i += 1) { + const double phase = + 2.0 * kTestPi * 4000.0 * static_cast(i) / src96k.sampleRate; + const auto v = static_cast(std::lround(7000.0 * std::sin(phase))); + samples[i * 2] = v; + samples[i * 2 + 1] = v; + } + AudioDecimatorState reused; + std::vector scratch; + convertAudioWithGain( + signalD.data(), static_cast(93 * src44f.blockAlign), src44f, dst48p, 1.0, + scratch, reused); + std::vector afterInterp; + convertAudioWithGain( + integerPacket.data(), static_cast(integerPacket.size()), src96k, dst48k, 1.0, + afterInterp, reused); + AudioDecimatorState fresh; + std::vector fromCold; + convertAudioWithGain( + integerPacket.data(), static_cast(integerPacket.size()), src96k, dst48k, 1.0, + fromCold, fresh); + expect( + "os920-interp-to-integer-starts-cold", + afterInterp == fromCold && !afterInterp.empty() && + reused.pendingFrames() == fresh.pendingFrames(), + "match=" + std::to_string(afterInterp == fromCold) + " pending=" + + std::to_string(reused.pendingFrames())); + } + + // Finalizing an empty stream returns no bytes (and touches nothing else). + { + AudioDecimatorState fresh; + std::vector dest(7, 0xAB); + finalizeAudioConversion(dst44p, 1.0, dest, fresh); + expect( + "os920-finalize-empty-stream-no-bytes", + dest.size() == 7 && dest[0] == 0xAB, + "bytes=" + std::to_string(dest.size())); + } + + // finalizeAudioConversion appends after the last WHOLE frame of the + // target format; the documented precondition is an empty or + // frame-aligned `destination` (header contract). The PCM writers store + // through typed pointers, so the append point is frame-aligned by + // construction and a caller-supplied trailing partial frame is outside + // the contract. This pins the supported case: a frame-aligned prefill + // stays byte-identical, the tail lands strictly after it, sized exactly + // to the pending-frame count, in whole frames. + { + const AudioInputFormat finSrc = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + std::vector finSignal(200 * finSrc.blockAlign, 0); + auto* finSamples = reinterpret_cast(finSignal.data()); + for (size_t i = 0; i < 400; i += 1) { + finSamples[i] = 0.25f; + } + AudioDecimatorState finState; + std::vector streamed; + convertAudioWithGain( + finSignal.data(), static_cast(finSignal.size()), finSrc, dst44p, 1.0, + streamed, finState); + const uint64_t pending = finState.interpolationPendingFrames(); + const uint64_t emittedBefore = finState.interpolationEmittedFrames(); + std::vector dest(dst44p.blockAlign * 3, 0x5A); + const std::vector prefill = dest; + const size_t before = dest.size(); + finalizeAudioConversion(dst44p, 1.0, dest, finState); + expect( + "os920-finalize-appends-after-whole-frames", + pending > 0 && + dest.size() == before + static_cast(pending) * dst44p.blockAlign && + dest.size() % dst44p.blockAlign == 0 && + std::equal(prefill.begin(), prefill.end(), dest.begin()) && + finState.interpolationFinalized() && + finState.interpolationEmittedFrames() == emittedBefore + pending, + "pending=" + std::to_string(pending) + " bytes=" + + std::to_string(dest.size()) + " want=" + + std::to_string(before + static_cast(pending) * dst44p.blockAlign)); + } + + // Deterministic mixer coverage, at queue level: the mixer is constructed + // but its thread is never started and its timeline never begun, so pushes + // are plain sequential calls and the pause handshake resolves at once + // (pauseFlushed_ is true while the timeline has not started). The threaded + // tests above remain responsible for the live cushion flush and resume + // synchronization. + { + const AudioInputFormat mixFmt = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const AudioInputFormat sysFmt = makeFormat(MFAudioFormat_Float, 44100, 2, 32); + const AudioInputFormat micFmt = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + AudioMixer mixer( + mixFmt, sysFmt, micFmt, true, true, 1.0, + [](const BYTE*, DWORD, int64_t, int64_t) { return true; }); + + const std::vector sysSignal = stereoFloatDualSine(44100, 997.0, 501.0, 0.5, 4321); + const std::vector micSignal = stereoFloatDualSine(48000, 701.0, 303.0, 0.5, 4800); + const std::vector sysSizes = {160, 1, 480, 7, 99}; + const std::vector micSizes = {480, 3, 479, 1, 100}; + AudioDecimatorState sysDirect; + AudioDecimatorState micDirect; + std::vector sysExpect; + std::vector micExpect; + std::vector piece; + size_t sysCursor = 0; + size_t micCursor = 0; + size_t step = 0; + while (sysCursor < 4321) { + const size_t frames = std::min(sysSizes[step % sysSizes.size()], 4321 - sysCursor); + mixer.pushSystem( + sysSignal.data() + sysCursor * sysFmt.blockAlign, + static_cast(frames * sysFmt.blockAlign)); + convertAudioWithGain( + sysSignal.data() + sysCursor * sysFmt.blockAlign, + static_cast(frames * sysFmt.blockAlign), + sysFmt, + mixFmt, + 1.0, + piece, + sysDirect); + sysExpect.insert(sysExpect.end(), piece.begin(), piece.end()); + sysCursor += frames; + const size_t micFrames = std::min(micSizes[step % micSizes.size()], 4800 - micCursor); + if (micFrames > 0) { + mixer.pushMicrophone( + micSignal.data() + micCursor * micFmt.blockAlign, + static_cast(micFrames * micFmt.blockAlign)); + convertAudioWithGain( + micSignal.data() + micCursor * micFmt.blockAlign, + static_cast(micFrames * micFmt.blockAlign), + micFmt, + mixFmt, + 1.0, + piece, + micDirect); + micExpect.insert(micExpect.end(), piece.begin(), piece.end()); + micCursor += micFrames; + } + step += 1; + } + while (micCursor < 4800) { + const size_t micFrames = std::min(micSizes[step % micSizes.size()], 4800 - micCursor); + mixer.pushMicrophone( + micSignal.data() + micCursor * micFmt.blockAlign, + static_cast(micFrames * micFmt.blockAlign)); + convertAudioWithGain( + micSignal.data() + micCursor * micFmt.blockAlign, + static_cast(micFrames * micFmt.blockAlign), + micFmt, + mixFmt, + 1.0, + piece, + micDirect); + micExpect.insert(micExpect.end(), piece.begin(), piece.end()); + micCursor += micFrames; + step += 1; + } + expect( + "os920-mixer-streams-keep-independent-resampler-state", + AudioMixerTestAccess::systemQueue(mixer) == sysExpect && + AudioMixerTestAccess::microphoneQueue(mixer) == micExpect, + "sys=" + std::to_string(AudioMixerTestAccess::systemQueue(mixer).size()) + " want " + + std::to_string(sysExpect.size()) + " mic=" + + std::to_string(AudioMixerTestAccess::microphoneQueue(mixer).size()) + " want " + + std::to_string(micExpect.size())); + + // setPaused(true) preserves both queues and ignores pushes. + mixer.setPaused(true); + const std::vector sysBefore = AudioMixerTestAccess::systemQueue(mixer); + const std::vector micBefore = AudioMixerTestAccess::microphoneQueue(mixer); + std::vector distinctive(100 * micFmt.blockAlign, 0); + auto* distinctiveSamples = reinterpret_cast(distinctive.data()); + for (size_t i = 0; i < 200; i += 1) { + distinctiveSamples[i] = 0.75f; + } + mixer.pushSystem(distinctive.data(), static_cast(distinctive.size())); + mixer.pushMicrophone(distinctive.data(), static_cast(distinctive.size())); + expect( + "os920-mixer-paused-pushes-ignored", + AudioMixerTestAccess::systemQueue(mixer) == sysBefore && + AudioMixerTestAccess::microphoneQueue(mixer) == micBefore, + "sys=" + std::to_string(AudioMixerTestAccess::systemQueue(mixer).size()) + " mic=" + + std::to_string(AudioMixerTestAccess::microphoneQueue(mixer).size())); + + // The resume clears both queues (and cold-resets both states). + mixer.setPaused(false); + expect( + "os920-mixer-resume-clears-queues", + AudioMixerTestAccess::systemQueue(mixer).empty() && + AudioMixerTestAccess::microphoneQueue(mixer).empty(), + "sys=" + std::to_string(AudioMixerTestAccess::systemQueue(mixer).size()) + " mic=" + + std::to_string(AudioMixerTestAccess::microphoneQueue(mixer).size())); + + // Post-resume pushes convert as a cold start: no pre-pause sample or + // phase contamination. (This also pins the accepted behavior change: + // the resume drops the bounded unfinalized tail of the pre-pause + // streams along with the queued bytes.) + const std::vector sysPost = stereoFloatDualSine(44100, 1234.0, 432.0, 0.4, 1234); + const std::vector micPost = stereoFloatDualSine(48000, 888.0, 222.0, 0.4, 960); + AudioDecimatorState sysCold; + AudioDecimatorState micCold; + std::vector sysColdExpect; + std::vector micColdExpect; + sysCursor = 0; + micCursor = 0; + step = 0; + while (sysCursor < 1234 || micCursor < 960) { + if (sysCursor < 1234) { + const size_t frames = std::min(sysSizes[step % sysSizes.size()], 1234 - sysCursor); + mixer.pushSystem( + sysPost.data() + sysCursor * sysFmt.blockAlign, + static_cast(frames * sysFmt.blockAlign)); + convertAudioWithGain( + sysPost.data() + sysCursor * sysFmt.blockAlign, + static_cast(frames * sysFmt.blockAlign), + sysFmt, + mixFmt, + 1.0, + piece, + sysCold); + sysColdExpect.insert(sysColdExpect.end(), piece.begin(), piece.end()); + sysCursor += frames; + } + if (micCursor < 960) { + const size_t frames = std::min(micSizes[step % micSizes.size()], 960 - micCursor); + mixer.pushMicrophone( + micPost.data() + micCursor * micFmt.blockAlign, + static_cast(frames * micFmt.blockAlign)); + convertAudioWithGain( + micPost.data() + micCursor * micFmt.blockAlign, + static_cast(frames * micFmt.blockAlign), + micFmt, + mixFmt, + 1.0, + piece, + micCold); + micColdExpect.insert(micColdExpect.end(), piece.begin(), piece.end()); + micCursor += frames; + } + step += 1; + } + expect( + "os920-mixer-resume-cold-resets-resampler-state", + AudioMixerTestAccess::systemQueue(mixer) == sysColdExpect && + AudioMixerTestAccess::microphoneQueue(mixer) == micColdExpect, + "sys=" + std::to_string(AudioMixerTestAccess::systemQueue(mixer).size()) + " want " + + std::to_string(sysColdExpect.size()) + " mic=" + + std::to_string(AudioMixerTestAccess::microphoneQueue(mixer).size()) + " want " + + std::to_string(micColdExpect.size())); + } + + // Starvation resume: a source that ran dry + // and came back is a real-time discontinuity — the starved branch pads + // silence and re-anchors the packet at now. The resumed packet must + // convert as a COLD stream: no pre-gap sample may reach the resumed + // output. The pre-fix stateless converter never interpolated across a + // gap either, because it held no state at all. The starved branch's + // queue.assign replaces whatever the queue held (lag is zero while the + // timeline is not anchored, as in an unstarted mixer), so the queue after + // the resumed push is EXACTLY the resumed conversion — pinned byte for + // byte, frame count for frame count, against a fresh-state conversion. + // On the unfixed tree this fails twice over: the continued stream emits + // one extra frame (92 vs 91) and its first frame blends the pre-gap + // constant into the resumed segment (-12594 vs +16384 at 48k->44.1k). + { + const AudioInputFormat mixFmt = makeFormat(MFAudioFormat_PCM, 44100, 2, 16); + const AudioInputFormat sysFmt = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + AudioMixer mixer( + mixFmt, sysFmt, sysFmt, true, false, 1.0, + [](const BYTE*, DWORD, int64_t, int64_t) { return true; }); + + std::vector preGap(150 * sysFmt.blockAlign, 0); + auto* preSamples = reinterpret_cast(preGap.data()); + for (size_t i = 0; i < 300; i += 1) { + preSamples[i] = -0.5f; + } + for (size_t off = 0; off < 150; off += 50) { + mixer.pushSystem( + preGap.data() + off * sysFmt.blockAlign, + static_cast(50 * sysFmt.blockAlign)); + } + std::vector resumed(100 * sysFmt.blockAlign, 0); + auto* resumedSamples = reinterpret_cast(resumed.data()); + for (size_t i = 0; i < 200; i += 1) { + resumedSamples[i] = 0.5f; + } + AudioMixerTestAccess::setSystemStarved(mixer, true); + mixer.pushSystem(resumed.data(), static_cast(resumed.size())); + + std::vector coldExpect; + AudioDecimatorState coldState; + convertAudioWithGain( + resumed.data(), static_cast(resumed.size()), sysFmt, mixFmt, 1.0, + coldExpect, coldState); + const std::vector& starveQueue = AudioMixerTestAccess::systemQueue(mixer); + expect( + "os920-mixer-starvation-resume-no-cross-gap-blend", + starveQueue == coldExpect, + "queue=" + std::to_string(starveQueue.size()) + " cold=" + + std::to_string(coldExpect.size()) + " q0=" + + std::to_string(starveQueue.size() >= 2 + ? *reinterpret_cast(starveQueue.data()) + : 0)); + + // Control: integer decimation keeps its pre-existing cross-gap + // behavior — the fix must not reset it. A resumed 96k->48k packet + // converts on the CONTINUING integer state, byte-identical to a + // direct conversion that never saw the abandonment hook. + const AudioInputFormat intMixFmt = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const AudioInputFormat intSrcFmt = makeFormat(MFAudioFormat_Float, 96000, 2, 32); + AudioMixer intMixer( + intMixFmt, intSrcFmt, intSrcFmt, true, false, 1.0, + [](const BYTE*, DWORD, int64_t, int64_t) { return true; }); + AudioDecimatorState intContinuing; + std::vector intPiece; + for (size_t off = 0; off < 150; off += 50) { + intMixer.pushSystem( + preGap.data() + off * intSrcFmt.blockAlign, + static_cast(50 * intSrcFmt.blockAlign)); + convertAudioWithGain( + preGap.data() + off * intSrcFmt.blockAlign, + static_cast(50 * intSrcFmt.blockAlign), intSrcFmt, intMixFmt, 1.0, + intPiece, intContinuing); + } + AudioMixerTestAccess::setSystemStarved(intMixer, true); + intMixer.pushSystem(resumed.data(), static_cast(resumed.size())); + std::vector intResumedExpect; + convertAudioWithGain( + resumed.data(), static_cast(resumed.size()), intSrcFmt, intMixFmt, 1.0, + intResumedExpect, intContinuing); + const std::vector& intQueue = AudioMixerTestAccess::systemQueue(intMixer); + expect( + "os920-mixer-starvation-resume-integer-continues", + intQueue == intResumedExpect, + "queue=" + std::to_string(intQueue.size()) + " expect=" + + std::to_string(intResumedExpect.size())); + + // Control: same-rate (T==S) resume output is identical cold or + // continued — nothing is deferred or retained on that path, before + // and after the fix alike. + const AudioInputFormat sameMixFmt = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const AudioInputFormat sameSrcFmt = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + AudioMixer sameMixer( + sameMixFmt, sameSrcFmt, sameSrcFmt, true, false, 1.0, + [](const BYTE*, DWORD, int64_t, int64_t) { return true; }); + sameMixer.pushSystem(preGap.data(), static_cast(preGap.size())); + AudioMixerTestAccess::setSystemStarved(sameMixer, true); + sameMixer.pushSystem(resumed.data(), static_cast(resumed.size())); + std::vector sameColdExpect; + AudioDecimatorState sameCold; + convertAudioWithGain( + resumed.data(), static_cast(resumed.size()), sameSrcFmt, sameMixFmt, 1.0, + sameColdExpect, sameCold); + const std::vector& sameQueue = AudioMixerTestAccess::systemQueue(sameMixer); + expect( + "os920-mixer-starvation-resume-same-rate-identical", + sameQueue == sameColdExpect, + "queue=" + std::to_string(sameQueue.size()) + " cold=" + + std::to_string(sameColdExpect.size())); + } +} +#endif // !OS920_PRISTINE_REGRESSION + +void runResampleContinuityTests() { + runResampleContinuityCoreTests(); +#ifndef OS920_PRISTINE_REGRESSION + runResampleContinuityFixedTests(); +#endif +} + } // namespace -int main() { +int main(int argc, char** argv) { + const bool continuityOnly = + argc > 1 && argv[1] == std::string("--resample-continuity-only"); + if (continuityOnly) { + runResampleContinuityTests(); + std::cout << "ran " << g_ran << " tests\n"; + if (g_failed != 0) { + std::cout << g_failed << " failed\n"; + return 1; + } + return 0; + } + // --- Timer resolution (getopenscreen/openscreen#921) ---------------------- // // First, before anything in this process could have raised it: a 5 ms sleep @@ -248,6 +1615,9 @@ int main() { expect("timer-5ms-sleep-lasts-about-5ms", after < 8.0, detail); } + // --- issue #920, item 24: non-integer resampling continuity -------------- + runResampleContinuityTests(); + const AudioInputFormat diagnostic = makeFormat(MFAudioFormat_Float, 96000, 8, 32); const AudioInputFormat snapped = makeAacCompatibleAudioFormat(diagnostic); expect( @@ -609,9 +1979,12 @@ int main() { std::to_string(out.size())); } - // reset() has to mean cold, not nearly cold. start(), beginTimeline() and - // setPaused(true) all call it, so anything it left behind would be audible - // in the first frames after a pause. + // reset() has to mean cold, not nearly cold. start() and beginTimeline() + // call it, and a resume reaches it through resetSources(); setPaused(true) + // deliberately does NOT -- it preserves the queued audio and both + // resampler states so the cushion flush can write them (the resume clears + // both). Anything reset() left behind would be audible in the first + // frames after a pause. { const size_t warmFrames = 512; const size_t tailFrames = 2048; diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index d8057409e..521cd4359 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -857,3 +857,4 @@ On macOS 15.2+ it opens at launch, until it has been closed once, while one of i | 2026-10-03 | installed `v2.0.0-rc.13`, CI-built DMG `Openscreen-macOS-Apple-Silicon-2.0.0-rc.13.dmg` from the GitHub Release (tag `018d22ab`), installed over rc.12, `spctl`: accepted, Notarized Developer ID (M4LK7C6S84), About reports `2.0.0-rc.13` | macOS 26.5 (25F71), Mac mini M1, 3840×2160, no camera, no microphone | Partial, no defect | **Scoped to the app changes between rc.8 and rc.13, on macOS**: the Windows row above covers the same diff on Windows and leaves out #967, c2a8559e and macOS, which this row takes. Real OS mouse and keyboard input through computer-use (screenshots worked on this Mac); Apple's picker is owned by Control Centre and cannot be granted, so the operator clicked *Share Entire Screen* for each take. **c2a8559e (HUD kept out of takes after the editor round trip):** take 1 picked from the HUD's record button (`excludedWindowIds: [582]`), stop, editor, Record mode showed the source as *Choose what to record*, *Start recording* reopened Apple's picker instead of reusing the pick, take 2 recorded with `excludedWindowIds: [639]`; frames extracted from both files (28:26 and 13:04) show no HUD where it sat. **#967 (no camera on this Mac):** with `camEnabled: true` and no camera identity in `recording-settings.json`, launch rewrote it to `false` and the HUD showed the camera off; same with a stale identity (`camDeviceId` + `camDeviceName` of an unplugged camera); clicking the toggle keeps it off with the "Camera access is blocked" toast; Record mode reads *Camera Off*. **#961:** closing the editor after take 2 (countdown overlay, Record mode handover) left 0 processes; so did closing it after a long editor session (eight project opens through the native panel, transcriptions, Media mode, an export, the export dialog's save panel cancelled). **#966:** with *Add zoom* bound to X in `shortcuts.json`, the empty zoom lane reads "Press X to add zoom", and "Appuyez sur X pour ajouter un zoom" / "Drücke X, …" in French and German. **Wordmark menu (3bee2d25):** French and German, every row on one line, "2.0.0-rc.13" in one piece. **#960 (b834416c):** project A with captions → project B → A again: captions show at once at 0:06.4, 0:10.2 and 1:05.2 without touching *Show captions* (0:05.3 shows none, a 4.77 to 5.57 s silence in the saved words). **Preview:** playback from 0:12 to the end across four transcript silence cuts, a 2× and a 16× region: picture and captions follow, the readout advances with the edit, stops at 1:06.2; no stall seen, 8 `[pipeline] décodage` opens for one seek and four cuts (consistent with a preload per cut; not compared with rc.12). **227b9b84 / 83fbcecc:** a two-clip project whose first clip is a file with 8 s of video and 10 s of audio (no camera): seeking from clip 2 to 0:09.5 shows clip 1's last frame, the same as 0:07.7, no stderr. A video-only file plays with its picture (e73097c8). **#968:** project whose first asset is `chmod 000`: `MediaUnreadableError … Permission denied` in the main log, clip 2 still transcribed, the asset card shows "Transcription failed" and "Couldn't read this media's audio. Make sure OpenScreen can access the folder it's in, then regenerate." without the IPC wrapper; after `chmod 644`, *Regenerate* runs again (not persisted as no-audio). **Metal compositor (8caaa20d, dd5e914b, 0624c148, 2fb994eb):** laptop frame, 3D cursor, click impact, image background blurred 53 %, Aurora, captions; MP4 1080p60 export of 3374 frames, video and audio 56.23 s against 56.21 s computed (66.154 s minus 5.09 s of cuts minus the 2× and 16× gains), full decode clean; extracted frames show the laptop with its shadow, the blurred background, the 3D cursor with its shadow, rounded screen corners and captions. During playback the preview followed image → image, blur 52 → 1 %, image → colour and back. **Also seen:** the playhead keeps its time when another project is opened (0:41.0 from A shown in B and C). With a hand-written `camDeviceName` and a null `camDeviceId`, the HUD keeps the camera on and the file at `true` (`useCameraHudSync` compares the empty selection with the empty id and returns early); the app always writes both together, so this state is artificial. Out of scope: a 10 s extract with speech under loud video sound transcribes as no speech in Auto (detected `en`, p 0.35) and as "*Musique*" in French. **Not run:** webcam and microphone (none on this Mac), so the Windows row's webcam-past-the-end defect cannot be checked here; click impact not confirmed (the fixture's cursor sidecar has no clicks, and none was identified around the nine clicks of take 2 at preview scale); Aurora motion on a plain colour; the 4× preview lag of the rc.12 row (not re-measured: this pass played 2× and 16× regions without timing the lag); `Esc`; tray; GIF; A/B against rc.12; Gatekeeper (the DMG came through `gh`, no quarantine flag); Intel. | | 2026-10-03 | installed `v2.0.0-rc.15` (build run 37124242835, tag `ccd8b449`, NSIS sha512 matched `latest.yml`), installed over rc.12 for all users, About reports `2.0.0-rc.15` (`win32 x64 · nsis`, Electron 41.2.1, Chromium 146.0.7680.188, Node 24.14.1). `v2.0.0` was promoted from this candidate during the pass and differs from it only by the version bump (`eff86dcb`), so the results hold for the stable code | Windows 11 Home 26200, 1920×1080 @ 125 %, AMD Ryzen 5 7520U / Radeon iGPU, built-in USB2.0 HD UVC WebCam (best mode 1280×720@30), Microphone Array (AMD) | Partial — 6 defects | **Whole-file pass on Windows.** Real OS mouse and keyboard input throughout. Computer-use screenshots came back grey again, so the app was observed through `PrintWindow` on its own windows and DOM reads over CDP, and every picture and sound claim below was measured on the file with ffprobe/ffmpeg. **Defects:** (1) #1004, HUD drag at 125 %: the HUD window grows about 1.5 px per drag step (1180×882 → 1232×935 over ~43 steps) and the bar jumps by half the growth on release; after a long drag down the bar sat partly under the taskbar (centre y 1042, work area ends at 1032). (2) #1005, *Edit clip*: Reset, Cancel and Apply sit inside the scrolling body. In a 1240×1000 window (794 css px tall) the card is clipped above Apply, and a click where Apply should be lands on the backdrop, closing the dialog and discarding the edit. It fits when the window is maximized. (3) #1006, the rail's "Choose a clip to edit" menu renders under the timeline toolbar: Clip 2 and Clip 3 are covered (`elementFromPoint` returns `_tlToolbar`) and clicks on them do nothing. (4) #1007, a clip duplicated with Ctrl+C / Ctrl+V does not carry its anchored zoom or Full Camera region: `duplicateClip` in `src/lib/ai-edition/document/timeline.ts` copies only `trimRanges`. (5) #1008, zoom repel across a clip boundary: a zoom dragged into a neighbour that ends at its clip's end jumps past it into the next clip, shrinking from 12.97 to 8.23 s and leaving a zero-length fragment on the first clip. Ctrl+Z restores it. (6) #1009, the wordmark menu does not close on a click on the bare top bar. The header is a `-webkit-app-region: drag` area, which never delivers the document `mousedown` that `AppMenu` listens for. It does close on the preview, on a top-bar button and on the wordmark. **Passed: HUD and capture.** One HUD, `[content-protection] OFF` logged; horizontal ↔ vertical layout; every tooltip, idle and recording, beside or above the bar and unclipped; language menu (15 locales); camera and microphone toggles in one click; device settings: three inputs, level meter, live camera preview, camera quality row (1080p persisted across a relaunch; hand-edited 720p and an absent key both read 4K). Hide bar, then the tray overflow icon brings the HUD back; Quit leaves 0 processes; a second launch exits 0. With no source the record tooltip reads "Choose a screen or window to record" and one click opens the picker, then starts. While recording: source button disabled, toggles `aria-disabled`, gear inert, tray tooltip "Recording: Tout l'écran". Pause freezes the timer and resume advances it; restart starts a new helper and deletes the first file; cancel deletes the file and opens no editor. Stop opens the editor with "Recording added to a new project". Screen H.264 High bt709 1920×1080; AAC 48 kHz; webcam 1280×720 ~30 fps at 8 Mbit/s, the camera's best mode, not upscaled, matching `webcamFormat`. Microphone-only, system-audio-only and all-off takes behave; window source 1270×668, non-black; an odd 1001×601 window gives an even 992×596 file; a context menu inside the window is recorded and cut at its edge. Without the flag the HUD and an open Notes window are absent from the take. Record mode: rows, live camera preview, microphone meter, device switch, source modal with badge, *Start recording* hands over to the HUD; auto-zoom on gives one merged zoom over three clicks, off gives none, and the setting persists; editable cursor off hides the auto-zoom row and the Cursor facet; *Hide desktop icons* shows a bare wallpaper and restores the icons after. **Passed: editor.** Rename, Saved dot, tabs, chat panel toggle, tooltips with key chips, dividers; play, pause, seek playing and paused, ±1 frame arrows, stop at end; ruler click and drag, navigator narrow and pan, labels without collision, playhead within 0.5 px; Ctrl+S toast with a stable top bar. Clips: media card drag and *Add to timeline*, reorder, Edit clip grips, 1:1 crop held through a corner drag, Free, Cancel, pencil, delete. Trim, zoom (levels, custom, out-of-range message, focus drag, 3D modes), speed (presets, custom, 16× cap message, playback at 16×), annotation (text, sizes, plates, palette, Typewriter, image, arrow, blur mosaic and smooth, oval, clamped, dragged into padding); Ctrl+D, Ctrl+Z, Ctrl+Shift+Z. Modifiers inside a trim fire on the parked frame. Copy and paste of zoom, annotation and trim with their toasts and properties; an empty copy is a no-op; deleting a clip removes its modifiers; Edit clip clamps them; a Full Camera region across a junction splits and merges as clips move. Imported music: fragments across a junction with `offsetMs`, −18 dB and 1 s fades, gain, *Reset audio*; voiceover lands at the playhead. Transcript: clip order, silences, monotonic word seeks, live cue, Backspace skip and restore, silence trim; captions in four styles and six anchors; French translation with a *Display* row, and deleting it leaves the words untouched; typing in the packaged gate does nothing; a double-click correction survives *Regenerate as English* (model reused); 101 languages, detected language on the card. Composition: colour, gradient and one-colour gradient, wallpaper, animations move during playback only (21–23 distinct frames in 3 s, None 1), blur, 9:16 with Whole and Follow cursor, Auto padding with even borders, shadows, four frame styles in both themes. Camera layout: four presets, mirror, square, size (default 40, max 60), roundness, positions; background Cutout, Blur with an intensity slider, Custom image and colour, and the custom-colour mask reaches a 720p export; webcam crop corner zoom (124 %) and frame move. Audio facet: +6.5 dB raises the export from −17.2 to −12.7 LUFS with the peak held at −1.3 dBFS; reset returns 0 dB. Cursor facet: hide, style, size (to 5.8 of 6) and 3D cursor all show in the preview. Depth of field appears once a zoom has a 3D preset and changes the preview. Wordmark menu: rows as listed, About row version, arrows wrap both ways; Keyboard Shortcuts as one dialog: Z → Q changes the lane hint, Q adds a zoom, Z does nothing, reset restores Z; Ctrl+O; AI settings as one dialog; light and dark themes, dialogs included; French UI with one-line menu rows (#969), back to English; About lists the versions and channel, and Copy puts the same block on the clipboard; the HUD panel's version row and Check for Updates ("Checking…", disabled, the same dialog as the menu, re-enabled after). AI chat (deepseek): "add one zoom at 2x from 5 s to 8 s" lands as 5–8 s at 2.20× with the reason given, is undone by one Ctrl+Z and redone. Closing the editor quits the app and writes `editor-window.json` (`maximized: false`); after a relaunch the last project's settings and the main project's two clips, crop, regions, music, voiceover and captions are as left, and a paste before any copy changes nothing. A schema-4 project from July opens, migrates to schema 8, and keeps its clip, seven zooms and annotation. **Export:** MP4 1080p30 of the two-clip project, 10288 frames, audio and video within one frame, −16.0 LUFS and −1.4 dBTP, annotation upright, caption plate at x = 108 for a 10 % inset, Full Camera where mapped, music ducked about 10 dB under speech with fades at the track's own edges, muted track absent, voiceover not stretched by a 2× region. GIF 25 fps: cancel removes the partial file and leaves the existing destination untouched; the full render (~127 s, #952) gives 434 frames, infinite loop, consistent in ffmpeg and GDI+. **Also seen:** a take with a ~4 s pause had audio 1.09 s past its video, against 0.02–0.17 s without a pause; three dedicated paused takes gave 0.40, 0.15 and 0.03 s. The export ends each clip at its last video frame (10288 frames against the 10320 the progress counted), so the timeline and the export disagree by that tail. The editor reopens 4 px taller than it closed at 125 %. **Not run:** the A/V offset across a pause measured directly (the flash-and-beep rig never painted on the captured desktop), the HUD with no camera (needs the camera disabled in Windows), tray right-click menus (Update Settings, Stop Recording, Save Diagnostics: Explorer is granted click-only), the Alt Help menu (an Alt tap showed nothing, inconclusive), Ctrl/Shift+wheel on the timeline, cursor auto-hide, smoothing, motion blur and click effects, annotation animations other than Typewriter, Depth of field in an export, chat rewind, history and Smart cuts, a software-encoder take, the crackle and hole scan, multiple displays, 4K and 1440p cameras, 150 % DPI, macOS and Linux. | | 2026-10-03 | `main` @ `0624c148` (sources identical to `v2.0.0-rc.13` in `crates/`, `src/`, `electron/`), packaged locally (`electron-builder --mac dir --arm64`, ad hoc signed), `compositor_view.node` SHA256 `7c1d38d664d29fa78615c386aa86e53456f1e662289ad53a2a128d588b99d560`; baseline the installed `v2.0.0-rc.12` DMG (Developer ID) | macOS 26.5 (25F71), Mac mini M1, 1920×1080 @ 2× | Partial, no defect | **Shader split, corner fast path, background cache and trail scissor (#982, #983, #984) on Metal. Not a computer-use pass:** `request_access` was refused with nobody at the machine, so the editor was driven over CDP (synthesised clicks on the inspector, preview pixels read from the canvas). `cargo test -p openscreen-compositor --lib --tests`: 407 passed, 0 failed, `every_shader_entry_point_compiles` (with the `LAYER_MODELS` library) and the macOS device-frame and 3D-cursor tests included; the `tests/` render binaries are Windows/Linux only and ran 0. **Export** (`openscreen export`, MP4 1080p60): bench project plus four variants (laptop + 3D cursor + click impact + blur, phone dark + Prism Glow + Aurora gradient, monitor + click impact + colour, window + full blur), frames extracted with ffmpeg: device frames with their shadows, 3D cursor with its shadow, click impact, trail, rounded corners and backgrounds all present. Same projects exported losslessly (`ffv1` candidate added locally, measurement only) with the rc.12 and main addons in one bundle: luma differs by 1/255 on anti-aliased edges, and by up to 20/255 on at most 159 px of a few frames along the 3D models' silhouettes (Metal fast math, models now in their own library); nothing missing. **Preview:** wallpaper image → gradient → colour → back, blur 50 % → 0 %, aspect 1:1 → Auto each return the exact starting frame; a static background holds still over 1 s of playback, *Aurora* animates; laptop and phone frames draw their shadows. **Perf** (screen-recorder-benchmark `commons-upload`): rc.12 1.134× floor, main 1.066×, figures in `rendering-performance.md`. **Not covered:** real recording, HUD, tray, computer-use input, the GUI export dialog, Escape, a 1.11.0-rc.1 run in the same session (discarded: a Parsec session went live mid-run), Windows (the D3D11 port is still unrun on a real GPU) and Linux. | +| 2026-10-11 | dev helper from `fix/os920-resample-continuity` @ `aa27e6fdf` (audio fix commits `50a433168` cross-packet continuity + `e29119445` starvation-resume restart; helper and test exe built from the committed tree with `npm run build:native:win`; test-exe sha256 `bb6921d0…`); historical evidence archived under former fork staging PR `My-Denia/openscreen#23` for study issue #920 §24; current submission: upstream PR [getopenscreen/openscreen#1113](https://github.com/getopenscreen/openscreen/pull/1113), open, with CI green on the head under review | Windows 11 build 26340.9616; **both audio endpoints fixed 48 kHz shared mode** (measured: single-valued 480-frame/10 ms packets, 0 non-aligned) | Partial — no defect found; two paths not real-machine testable on this host | **Audio resampling continuity slice (#920 item 24), helper-level + real-machine hybrid.** The defect: `convertAudioWithGain`'s non-integer branch held no cross-packet state, so identical input produced different output depending on packet boundaries (48k→44.1k, 100-frame packets: 14700→14720 frames, drifting positions). The fix adds cumulative integer-rational output accounting (K(N)/C(N)), absolute-position interpolation with the legacy arithmetic preserved, bounded retention, and a `finalizeAudioConversion` tail used only by the stateless test-facing overload; a second commit restarts the interpolation stream at mixer starvation-resume (automated-review P2: the new state could otherwise complete ≤⌈T/S+½⌉ deferred frames across a real-time gap using pre-gap samples — a blend the pre-fix stateless converter never made; integer decimation and same-rate paths keep their pre-existing cross-gap behavior, pinned by controls). **Evidence:** assertion-based old-FAIL on the pristine pinned base (9 named failures) → new-PASS; full native suite **176/176** and focused 54/54 on that base; starvation fix proven behaviorally old-FAIL (`50a433168`: 1 failing mixer-path test — resumed queue 368 bytes vs cold 364, first frame −12594 = pre-gap/post-gap blend vs cold +16384) → PASS after `e29119445`. **Submitted-tree native validation, separately attributed:** pre-chain validation of the candidate tree that became `5764aa2b6e` completed `npm run build:native:win` with 0 errors, full native suite **177/177**, and focused suite **55/55**, including `os920-finalize-appends-after-whole-frames` PASS. These are separate submitted-tree results, not a re-run or replacement of the historical pinned-base pass above. **Real machine (matched-rate only):** 6×60 s 48 kHz/48 kHz recordings (3 pre-fix/3 fixed helpers interleaved) + pause/resume takes — all valid, marker spacing 3.0000–3.001 s, old/new within each other's repeat envelope, zero clipping; captured real callback streams replayed through old/new converters **6/6 byte-identical**; no regression observed. **Mismatched-rate real capture (second pass, same day, owner-enabled):** the default render endpoint (JM6PRO) was switched to 16-bit/44100 Hz and the encoder target forced to 48000 via the production `OPENSCREEN_WGC_FORCE_AAC_SOURCE_RATE` surface (the video-rate-alignment path); five 35 s takes ran old×2 / fixed×2 / instrumented-fixed with the same marker harness. Every real loopback packet was exactly 441 frames (3496/3496 — this hardware produces perfectly aligned 10 ms geometry, so the packet-boundary defect class is not reachable on it and stays covered by the deterministic suites); decoded old vs fixed output is time-aligned at zero offset with 97.4 % byte-identical frames and residual maxdiff 170 (AAC block-boundary quantization from the one-frame first-packet emission difference — no drift, no regression); each build repeated byte-identically; the mixer's startup starvation-padding path ran on real hardware (~70 ms). Starvation/underrun beyond startup was **not** reproduced on real hardware — covered by the deterministic real-`AudioMixer`-path tests above; no full capture-to-export GUI cycle, editor/export/transcript sections, webcam, GIF or non-audio checks for this slice; macOS/Linux N/A (WGC-only code). Fork CI did not run at pass time (GitHub-side Actions suspension on the repo — settings and workflow states reported enabled/active while every dispatch returned 422; full evidence in the run); Actions was restored later the same day and CI has since run green on each exact head under review (runs 38119381114, 38121492689, and 38125479601 recorded for their respective exact heads — run evidence in the project archive). This CI has no native C++ job — the native evidence is the local MSVC build and suites above. Upstream PR #1113 is open, with CI green on the head under review; awaiting maintainer review. |