Skip to content

Add a stateful streaming API: seek(), processBlock(), flush() - #6

Draft
sanerdemirel wants to merge 4 commits into
gregogiudici:mainfrom
sanerdemirel:feat/stateful-streaming-process
Draft

Add a stateful streaming API: seek(), processBlock(), flush()#6
sanerdemirel wants to merge 4 commits into
gregogiudici:mainfrom
sanerdemirel:feat/stateful-streaming-process

Conversation

@sanerdemirel

Copy link
Copy Markdown

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 Stretch per block — which throws
away 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 against
    persistent state.
  • flush(output_samples) — drain the tail at the end of a stream.

process() is untouched and stays backwards-compatible, and
test_process_output_is_unchanged_backwards_compatible pins that rather than
asserting it in a comment.

Tests

9 new tests in tests/test_streaming.py, including:

  • streaming blocks match a one-shot reference;
  • a negative control — a persistent object must produce different output
    from a fresh-object-per-block, otherwise the test would pass even if the new
    API were silently resetting;
  • determinism across repeated runs;
  • a time-factor change between blocks takes effect and keeps pitch;
  • processBlock() before configure() raises a clear error.

examples/example_streaming.py shows the intended loop.

One open design question — genuinely your call

processBlock()'s default output length mirrors process()'s own rule
when output_samples is not given. That seemed the least surprising choice, but
an 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 than
truncates
. Below the natural tail length — blockSamples(), which is also
exactly inputLatency() + outputLatency() — the library reverses the part that
will 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). Upstream signalsmith-stretch main has since
reworked flush() to take a playbackRate and zero-pad to satisfy longer
requests, 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.

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
sanerdemirel force-pushed the feat/stateful-streaming-process branch from 3548fbe to d8f78c7 Compare September 8, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant