fix(sortformer): streaming mel emits phantom frames, drifting all timestamps late#807
Conversation
…t all timestamps late The incremental preprocessing center-padded the audio buffer on EVERY computeFlatTransposed call (nFFT/2 zeros each side) while the samplesConsumed inversion only accounted for one pad, so each effective preprocess call emitted frames covering nFFT - (nFFT - melWindow)/2 = 272 samples (17 ms) more timeline than the audio it consumed. With real-time feeding (one compute per 6-frame chunk) the finalized frame stream ran ~3.5% faster than the audio: 154.3 s of audio produced 1999 frames (nominally 159.9 s), and every frame-indexed timestamp (speaker turns, segment boundaries) drifted progressively late, reaching +4.5 s a few minutes in. Feeding the same audio in one addAudio call produced the correct count, which is why batch-fed tests and processComplete (which uses SortformerFeatureLoader) never caught it; the closest acknowledgment was the "known architectural limitation" note in SortformerTests. Same padding-accounting bug class as FluidInference#422/FluidInference#441/FluidInference#444, but silent instead of a shape mismatch because the feature buffer absorbs any frame count. Rework the streaming preprocessor to emit the session-global, batch-equivalent mel stream incrementally: - audioBuffer now always starts at sample melFramesEmitted * melStride - nFFT/2 of the virtually center-padded stream, seeded with the nFFT/2-zero batch left pad at reset; frames are computed from real samples only via .prePadded + expectedFrameCount, so feeding granularity cannot change frame count or values. - Frame k is emitted once its last covered sample (k * melStride + melWindow/2 - 1) has arrived; consumption is exactly count * melStride. - finalizeSession (and speaker enrollment, which feeds a complete clip) appends the batch right pad via padAndEmitRemainingMelLocked, bringing the stream to the exact batch frame count. The pad is a preemphasis-cancelling geometric decay so the in-place preemphasis filter reproduces the batch pad's exact zeros. Streaming now matches computeFlatTransposed(paddingMode: .center) over the whole session frame-for-frame at any feeding granularity; verified on a real 154 s two-speaker recording where finalized speaker turns had drifted 2.7-4.5 s late and now land within one frame of the tentative (real-time) detections.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Do you have a sample wav file or steps to reproduce this issue |
|
from ai
Two smaller asks while you're in there:
|
|
Yes! I made a small repro with Claude: https://github.com/predict-woo/fluidaudio-streaming-drift-repro It comes with a 72-second wav where two macOS The script runs this file through the diarizer twice. First it hands over the whole file at once, then it feeds the same file in small 100 ms pieces, like a live microphone would. The results should be identical, but on current
Just run |
…dio after finalize Address review on FluidInference#807: - enrollSpeaker now clears the enrollment clip's mel stream in a defer, so the not-enough-audio early return and thrown errors (cancellation, CoreML) no longer leave melInputExhausted latched, which silently disabled all further streaming until reset(). This also deduplicates the cleanup block the two later exit paths repeated verbatim. - addAudio and process(samples:) drop input with a one-time warning once the mel stream is exhausted; previously the audio buffer grew without bound after finalizeSession since nothing consumes it anymore. - Fold _realSamplesReceived = 0 into resetMelStreamLocked so the frame math can never run against a stale sample count. - Replace the no-op mel.prefix with an assert on the expected frame count. testSortformerFailedEnrollmentDoesNotDisableStreaming fails on the previous commit and passes now; it skips (not silently passes) when models cannot be downloaded.
…dio after finalize Address review on FluidInference#807: - enrollSpeaker now clears the enrollment clip's mel stream in a defer, so the not-enough-audio early return and thrown errors (cancellation, CoreML) no longer leave melInputExhausted latched, which silently disabled all further streaming until reset(). This also deduplicates the cleanup block the two later exit paths repeated verbatim. - addAudio and process(samples:) drop input with a one-time warning once the mel stream is exhausted; previously the audio buffer grew without bound after finalizeSession since nothing consumes it anymore. - Fold _realSamplesReceived = 0 into resetMelStreamLocked so the frame math can never run against a stale sample count. - Replace the no-op mel.prefix with an assert on the expected frame count. testSortformerFailedEnrollmentDoesNotDisableStreaming fails on the previous commit and passes now; it skips (not silently passes) when models cannot be downloaded.
|
All four should be fixed in 4ac06f1. The enrollment cleanup now lives in a |
Problem
The streaming preprocessor (
addAudio/process) center-pads the buffered audio on everycomputeFlatTransposedcall, but thesamplesConsumedinversion only accounts for one pad. Each preprocess call therefore emits frames coveringnFFT - (nFFT - melWindow)/2= 272 samples (17 ms) more timeline than the audio it consumes.With real-time feeding that is roughly one call per 6-frame chunk, so the finalized frame stream runs ~3.5% faster than the audio, and every frame-derived timestamp (speaker turns, segment boundaries) lags further behind the audio as the session runs. Feeding the whole file in a single
addAudiocall only pads once, which is why batch-fed tests andprocessComplete(which usesSortformerFeatureLoader) never hit this. It is the same padding-accounting family as #441 / #422 / #444, but silent: there is no static shape to trip a CoreML error, the feature buffer just absorbs the extra frames. The closest acknowledgment was the "known architectural limitation" note inSortformerTests.I hit this in a real app: on a 2.5 minute two-speaker recording, the finalized speaker turns were stamped 2.7 s late at the first turn and 4.5 s late a minute later, so each turn's opening seconds were attributed to the previous speaker, while the tentative (real-time) predictions looked fine.
Same demo script, same audio, against current
mainand this branch:Fix
Emit the session-global, batch-equivalent mel stream incrementally instead of re-padding per call:
audioBufferalways keepsnFFT/2samples of left context, seeded with the batch left pad at reset. Mid-stream frames are computed from real samples only (.prePadded+expectedFrameCount), consuming exactlycount * melStridesamples, so feeding granularity cannot change frame count or values.[k*hop - win/2, k*hop + win/2)is covered by received samples.finalizeSession(and speaker enrollment, which feeds a complete clip) appends the batch right pad and emits the trailing frames, reaching the exact.center-mode frame count. The pad is a preemphasis-cancelling decay so the in-place preemphasis filter reproduces the batch pad's exact zeros.Streaming output now matches
computeFlatTransposed(paddingMode: .center)of the whole session frame-for-frame at any feeding granularity, so the limitation note is gone too. No public API changes.Test plan
SortformerStreamingMelTests: identical chunk sequences and frame counts across feeding granularities (160 / 1600 / 4093 / 16000 / one-shot), finalized frame count matches the batch formula, streamed values match batch center-padded mel including the padded tail, and clean restart afterreset().