Add a stateful streaming API: seek(), processBlock(), flush() - #6
Draft
sanerdemirel wants to merge 4 commits into
Draft
Add a stateful streaming API: seek(), processBlock(), flush()#6sanerdemirel wants to merge 4 commits into
sanerdemirel wants to merge 4 commits into
Conversation
process() runs seek() -> process() -> flush() -> reset() inside a single Python call, and its own comment explains why the reset is there: "REMEMBER: Reset the stretch processor or we will get an error: free() invalid pointer". The underlying processor keeps pointers into the buffers process() passes it, and process() frees those buffers before returning, so it has to reset the processor first or the next call would hand it dangling pointers. That reset is also what makes process() a one-shot, self-contained cycle: the object can never carry state between calls, even though the underlying processor supports being driven block by block (it exposes its own separate process()/flush()/seek()). seek()/processBlock()/flush() add that block-by-block use without touching process() at all. They read from and write into scratch buffers this object owns for its whole lifetime, resized on demand and never freed mid-stream, so the pointers the underlying processor holds stay valid between calls. What a caller gets back from processBlock()/flush() is always a separate, freshly allocated array, so nothing the caller's garbage collector frees is memory the processor still points into. process()'s own buffers and behaviour are untouched. processBlock() takes an explicit output length per call (matching how the underlying processor already works), rather than computing it from timeFactor the way process() does for its single call, since a caller streaming audio needs to control the ratio block by block, e.g. to change speed mid-stream. Includes tests covering: process() is bit-identical to the unmodified build; a streamed sequence of blocks matches a one-shot reference, with a negative control showing a sign-inverted block is actually detected; a persistent object differs from a fresh object per block (process() cannot show this, since it always resets); determinism across repeated runs; a mid-stream time factor change taking effect with pitch preserved; and many blocks running without a crash. Also adds a self-contained example script (no audio file needed) covering the same ground as a runnable benchmark.
flush() reads as though it simply drains fewer samples when you ask for fewer. It does not. Below the natural tail length -- blockSamples(), which is also exactly inputLatency() + outputLatency() -- the library takes the part that will not fit, reverses it in time and subtracts it onto the end of the buffer you asked for. A short flush therefore carries more energy than the corresponding prefix of a full one. That is deliberate anti-truncation behaviour in the library, not a bug, and this change does not alter it. It only says so in the docstring, because nothing in the signature does. The consequence that is easy to get wrong in a streaming loop: flushing generously and slicing does not give the same audio as flushing exactly. flush(4 * n)[:n] and flush(n) differ. At or above the natural tail the output is prefix-stable and slicing is safe. Concretely, how I ran into it: I was measuring how far a stretched signal had been displaced in time, and asked flush() for four times the tail I actually wanted, on the assumption that a longer flush was a superset of a shorter one and could be sliced back down. It is not a superset. The fold had altered the samples I then sliced, so the measurement was quietly wrong, and the discrepancy looked like a bug in my own code rather than a property of flush(). A sentence in the docstring would have saved it.
sanerdemirel
force-pushed
the
feat/stateful-streaming-process
branch
from
September 8, 2026 08:04
3548fbe to
d8f78c7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
process()is one-shot: it seeks, processes, flushes and resets on every call.That is the right shape for stretching a whole buffer, but it means driving the
stretcher block by block requires a fresh
Stretchper block — which throwsaway the internal STFT state between blocks and leaves an audible seam at every
boundary.
This adds a stateful path alongside it, for callers who want to feed audio in
chunks and keep one processor alive across them.
What it adds
seek(input, block_samples, playback_rate)— prime the processor.processBlock(input, output_samples=None)— process one block againstpersistent state.
flush(output_samples)— drain the tail at the end of a stream.process()is untouched and stays backwards-compatible, andtest_process_output_is_unchanged_backwards_compatiblepins that rather thanasserting it in a comment.
Tests
9 new tests in
tests/test_streaming.py, including:from a fresh-object-per-block, otherwise the test would pass even if the new
API were silently resetting;
processBlock()beforeconfigure()raises a clear error.examples/example_streaming.pyshows the intended loop.One open design question — genuinely your call
processBlock()'s default output length mirrorsprocess()'s own rulewhen
output_samplesis not given. That seemed the least surprising choice, butan explicit required parameter may well be the better API — it makes the
caller state what they expect instead of inheriting a default they may not know
about. I have no attachment to the current behaviour; say which you prefer and
I will change it.
A documentation change worth calling out
flush()'s docstring now warns that a short flush folds rather thantruncates. Below the natural tail length —
blockSamples(), which is alsoexactly
inputLatency() + outputLatency()— the library reverses the part thatwill not fit and subtracts it onto the end of the buffer, so
flush(4*n)[:n]and
flush(n)return different audio.This is not a bug report and the behaviour is unchanged. It is deliberate
anti-truncation behaviour in the library. Nothing in the signature says so,
though, and it cost me a measurement before I understood it — so it seemed worth
writing down for the next person driving
flush()in a loop.Scope note: this branch is against the currently pinned submodule
(
ffa4598,1.1.0-8-gffa4598). Upstreamsignalsmith-stretchmainhas sincereworked
flush()to take aplaybackRateand zero-pad to satisfy longerrequests, which confines the fold to at most one interval. The docstring
describes the pinned behaviour, not
main's.Note
Opened as a draft on purpose — this is a new public API surface on your library
and I would rather have your direction before polishing it. Happy to split,
rename, or drop any part of it.