From 4e7fface3344a08b5acc39754dde586331cccb07 Mon Sep 17 00:00:00 2001 From: sanerdemirel <300525555+sanerdemirel@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:52:25 +0200 Subject: [PATCH 1/4] Add a stateful streaming API: seek(), processBlock(), flush() 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. --- examples/example_streaming.py | 134 +++++++++++++++++++ src/signalsmith-bindings.cpp | 203 +++++++++++++++++++++++++++- tests/test_streaming.py | 241 ++++++++++++++++++++++++++++++++++ 3 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 examples/example_streaming.py create mode 100644 tests/test_streaming.py diff --git a/examples/example_streaming.py b/examples/example_streaming.py new file mode 100644 index 0000000..37be7cd --- /dev/null +++ b/examples/example_streaming.py @@ -0,0 +1,134 @@ +"""Self-contained example/benchmark for the streaming API (seek/processBlock/ +flush). Needs no audio file -- run it directly: + + python examples/example_streaming.py +""" +import numpy as np +import python_stretch as m + +SR = 44100.0 + + +def demo_basic_streaming(): + # Drive a persistent Stretch object through several blocks and check + # the concatenated output matches a single one-shot call. + rng = np.random.default_rng(0) + total = rng.normal(0, 0.1, size=(1, 44100)).astype(np.float32) + chunk = 4410 + + reference = m.Signalsmith.Stretch(seed=0) + reference.preset(1, SR) + ref_out = reference.processBlock(total, total.shape[1]) + + streamed = m.Signalsmith.Stretch(seed=0) + streamed.preset(1, SR) + parts = [ + streamed.processBlock(total[:, i:i + chunk], chunk) + for i in range(0, total.shape[1], chunk) + ] + streamed_out = np.concatenate(parts, axis=1) + + print("one-shot vs streamed, bit-identical:", np.array_equal(ref_out, streamed_out)) + + +def demo_time_factor_change_mid_stream(): + # A 440Hz tone, stretched to half speed partway through, with pitch + # unaffected by the change. + freq = 440.0 + n = 44100 + t = np.arange(n) / SR + tone = (0.2 * np.sin(2 * np.pi * freq * t)).astype(np.float32).reshape(1, -1) + + s = m.Signalsmith.Stretch(seed=0) + s.preset(1, SR) + chunk, half = 4410, n // 2 + parts = [s.processBlock(tone[:, i:i + chunk], chunk) for i in range(0, half, chunk)] + parts += [s.processBlock(tone[:, i:i + chunk], chunk * 2) for i in range(half, n, chunk)] + out = np.concatenate(parts, axis=1) + + def dominant_freq(x): + window = np.hanning(len(x)) + spectrum = np.abs(np.fft.rfft(x * window)) + freqs = np.fft.rfftfreq(len(x), d=1 / SR) + return freqs[np.argmax(spectrum)] + + print("input length:", n, "output length:", out.shape[1], "(second half at half speed)") + print("dominant freq before speed change:", dominant_freq(out[0, :half]), "Hz") + print("dominant freq after speed change: ", dominant_freq(out[0, half:]), "Hz") + + +def demo_memory_is_bounded(): + # How this was actually measured (Windows): psapi.GetProcessMemoryInfo + # working-set size, before vs. after driving 50000 blocks through a + # single persistent Stretch object, with a short warmup first so the + # internal scratch buffers have already grown to their steady size. + # Measured result on the machine this was written on: 0 bytes of + # growth over 50000 blocks (512 samples each). If you're on Linux or + # macOS, swap in `resource.getrusage(resource.RUSAGE_SELF).ru_maxrss` + # instead -- the loop below is the part that matters. + import ctypes + import sys + + rng = np.random.default_rng(1) + s = m.Signalsmith.Stretch(seed=0) + s.preset(1, SR) + chunk = 512 + + for _ in range(200): + block = rng.normal(0, 0.1, size=(1, chunk)).astype(np.float32) + s.processBlock(block, chunk) + + if sys.platform == "win32": + from ctypes import wintypes + + class ProcessMemoryCounters(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("PageFaultCount", wintypes.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + psapi = ctypes.WinDLL("psapi.dll") + kernel32 = ctypes.WinDLL("kernel32.dll") + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + psapi.GetProcessMemoryInfo.argtypes = [ + wintypes.HANDLE, ctypes.POINTER(ProcessMemoryCounters), wintypes.DWORD, + ] + psapi.GetProcessMemoryInfo.restype = wintypes.BOOL + + def working_set_bytes(): + counters = ProcessMemoryCounters() + counters.cb = ctypes.sizeof(ProcessMemoryCounters) + psapi.GetProcessMemoryInfo(kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb) + return counters.WorkingSetSize + + before = working_set_bytes() + n_blocks = 50000 + for _ in range(n_blocks): + block = rng.normal(0, 0.1, size=(1, chunk)).astype(np.float32) + s.processBlock(block, chunk) + after = working_set_bytes() + print(f"working set growth over {n_blocks} blocks: {after - before} bytes") + else: + import resource + + before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + n_blocks = 50000 + for _ in range(n_blocks): + block = rng.normal(0, 0.1, size=(1, chunk)).astype(np.float32) + s.processBlock(block, chunk) + after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + print(f"max RSS growth over {n_blocks} blocks: {after - before} KB (ru_maxrss units)") + + +if __name__ == "__main__": + demo_basic_streaming() + demo_time_factor_change_mid_stream() + demo_memory_is_bounded() diff --git a/src/signalsmith-bindings.cpp b/src/signalsmith-bindings.cpp index c7ca08a..efc9f7b 100644 --- a/src/signalsmith-bindings.cpp +++ b/src/signalsmith-bindings.cpp @@ -1,7 +1,12 @@ #include "nanobind/nanobind.h" #include +#include #include "stretch/signalsmith-stretch.h" +#include +#include +#include + namespace nb = nanobind; using namespace nb::literals; @@ -66,6 +71,100 @@ struct Stretch{ Sample timeFactor_ = 1.f; Sample freqMultiplier_ = 1.f; Sample freqSemitones_ = 0.f; + + // Channel count as of the last preset()/configure() call. The + // underlying SignalsmithStretch doesn't expose a getter for this, + // so processBlock()/seek()/flush() below need their own copy of + // it to size their scratch buffers and to check incoming arrays. + int channels_ = 0; + + // Scratch owned by this object across calls, for processBlock(), + // seek() and flush() only -- process() above is untouched and + // keeps allocating and freeing its own buffers per call, exactly + // as before. Resized on demand and never freed early, so pointers + // handed to stretch_ stay valid between calls; what a caller gets + // back from processBlock()/flush() is always a separate, freshly + // allocated array (see toNumpy() below), so nothing the caller's + // garbage collector frees is memory stretch_ still points into. + std::vector> inScratch_; + std::vector> outScratch_; + + void requireConfigured() const { + if (channels_ <= 0) { + throw std::runtime_error( + "call preset()/configure() before seek()/processBlock()/flush()"); + } + } + + static void resizeScratch(std::vector> &scratch, + int channels, size_t n) { + if (static_cast(scratch.size()) < channels) { + scratch.resize(channels); + } + for (int c = 0; c < channels; ++c) { + if (scratch[c].size() < n) { + scratch[c].resize(n); + } + } + } + + // Copies audio_input into `scratch`, following the input's own + // strides rather than assuming a C-contiguous layout, and accepts + // either a 1-D (mono) or 2-D (channels, samples) array. Returns + // the number of samples per channel that were copied (the caller + // already knows the channel count -- it's channels_, checked + // below). + size_t copyIntoScratch(std::vector> &scratch, + const nb::ndarray &input) { + size_t ndim = input.ndim(); + if (ndim != 1 && ndim != 2) { + throw std::invalid_argument("input must be 1-D (mono) or 2-D (channels, samples)"); + } + int numChannels = (ndim == 1) ? 1 : static_cast(input.shape(0)); + size_t n = (ndim == 1) ? input.shape(0) : input.shape(1); + if (numChannels != channels_) { + throw std::invalid_argument( + "input channel count does not match preset()/configure()"); + } + int64_t channelStride = (ndim == 1) ? 0 : input.stride(0); + int64_t sampleStride = (ndim == 1) ? input.stride(0) : input.stride(1); + const Sample *inData = input.data(); + + resizeScratch(scratch, numChannels, n); + for (int c = 0; c < numChannels; ++c) { + for (size_t i = 0; i < n; ++i) { + scratch[c][i] = inData[c * channelStride + i * sampleStride]; + } + } + return n; + } + + static std::vector channelPointers( + std::vector> &scratch, int channels) { + std::vector ptrs(channels); + for (int c = 0; c < channels; ++c) { + ptrs[c] = scratch[c].data(); + } + return ptrs; + } + + // The one and only place a freshly heap-allocated, capsule-owned + // array is created for the caller -- everything stretch_ itself + // writes into is `outScratch_`, which this copies out of. + static nb::ndarray> toNumpy( + const std::vector> &scratch, + int channels, size_t n) { + Sample *out = new Sample[static_cast(channels) * n]; + for (int c = 0; c < channels; ++c) { + std::copy(scratch[c].data(), scratch[c].data() + n, + out + static_cast(c) * n); + } + size_t shape[2] = {static_cast(channels), n}; + nb::capsule owner(out, [](void *p) noexcept { + delete[] static_cast(p); + }); + return nb::ndarray>(out, 2, shape, owner); + } public: Stretch() : stretch_() {} Stretch(long seed) : stretch_(seed) {} @@ -107,10 +206,12 @@ struct Stretch{ stretch_.presetDefault(nChannels, sampleRate); } sampleRate_ = sampleRate; + channels_ = nChannels; } // === Manual configuration === void configure(int nChannels, int blockSamples, int intervalSamples) { stretch_.configure(nChannels, blockSamples, intervalSamples); + channels_ = nChannels; } // Set transpose factor @@ -223,6 +324,78 @@ struct Stretch{ // Create the output ndarray return nb::ndarray>(outData, 2, outShape, owner); } + + // === Streaming === + // + // process() above always runs seek() -> process() -> flush() -> + // reset() in a single call, and its own comment says why: + // "REMEMBER: Reset the stretch processor or we will get an error: + // free() invalid pointer". stretch_'s process() keeps pointers + // into the buffers passed to it, and process() above frees those + // buffers before returning, so it has to reset the processor + // first or the next call would hand stretch_ 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 stretch_ itself supports being driven block + // by block (see its blockSamples()/intervalSamples() and its own + // process()/flush()/seek(), used directly below). + // + // seek()/processBlock()/flush() give that block-by-block use + // directly: they read from and write into `inScratch_` / + // `outScratch_`, which this object owns for its whole lifetime + // and only ever grows, never frees mid-stream -- so the pointers + // stretch_ is holding stay valid across calls, and none of these + // three touch reset(). reset() is still here, unchanged, for + // whoever wants to start a new stream on the same object. + + // Feed pre-roll audio (history) without affecting the speed + // calculation, so playback can resume mid-stream without a cold + // start. + void seek(nb::ndarray audio_input, double playback_rate) { + requireConfigured(); + size_t n = copyIntoScratch(inScratch_, audio_input); + auto ptrs = channelPointers(inScratch_, channels_); + stretch_.seek(ptrs.data(), static_cast(n), playback_rate); + } + + // The call this addition exists for: no seek, no flush, and it + // never resets, so calling it again with the next block of input + // continues from where the previous call left off. If + // output_samples is omitted, it is computed from timeFactor, the + // same way process() computes its own output length. + nb::ndarray> processBlock( + nb::ndarray audio_input, + std::optional output_samples) { + requireConfigured(); + size_t inN = copyIntoScratch(inScratch_, audio_input); + // inN, not inScratch_[0].size(): the scratch buffer never + // shrinks, so its size can be larger than this call's actual + // input length if an earlier call passed more samples. + long outN = output_samples.has_value() + ? *output_samples + : std::lround(inN / timeFactor_); + if (outN < 0) { + throw std::invalid_argument("output_samples must be >= 0"); + } + resizeScratch(outScratch_, channels_, static_cast(outN)); + auto inPtrs = channelPointers(inScratch_, channels_); + auto outPtrs = channelPointers(outScratch_, channels_); + stretch_.process(inPtrs.data(), static_cast(inN), outPtrs.data(), static_cast(outN)); + return toNumpy(outScratch_, channels_, static_cast(outN)); + } + + // Drain the remaining output with no further input, e.g. at the + // end of a stream. + nb::ndarray> flush(long output_samples) { + requireConfigured(); + if (output_samples < 0) { + throw std::invalid_argument("output_samples must be >= 0"); + } + resizeScratch(outScratch_, channels_, static_cast(output_samples)); + auto outPtrs = channelPointers(outScratch_, channels_); + stretch_.flush(outPtrs.data(), static_cast(output_samples)); + return toNumpy(outScratch_, channels_, static_cast(output_samples)); + } }; // Assuming Sample is 'float' for simplicity @@ -291,7 +464,7 @@ NB_MODULE(Signalsmith, m) { "----------\n" "- timeFactor (float): Factor by which time is stretched or compressed (e.g., 0.5 slows down by half, 2.0 doubles speed).") - // PROCESSING + // PROCESSING .def("process", &Stretch::process, "audio_input"_a, "Process an input audio buffer and return the stretched or pitch-shifted output.\n\n" @@ -301,6 +474,34 @@ NB_MODULE(Signalsmith, m) { "Returns:\n" "----------\n" "- numpy.ndarray: Stretched or pitch-shifted output audio buffer.") + + // STREAMING + .def("seek", &Stretch::seek, + "audio_input"_a, "playback_rate"_a, + "Feed pre-roll audio (history) without affecting the speed calculation,\n" + "so a stream can resume mid-track without a cold start. Does not return\n" + "output and does not reset the processor.") + .def("processBlock", &Stretch::processBlock, + "audio_input"_a, "output_samples"_a.none() = nb::none(), + "Process one block of a stream and return its output. Unlike process(),\n" + "this never resets the processor: call it again with the next block of\n" + "input and it continues from where the previous call left off, so audio\n" + "can be driven through in a sequence of smaller blocks rather than all at\n" + "once. If output_samples is omitted, it is computed from timeFactor, the\n" + "same way process() computes its own output length.\n\n" + "Parameters:\n" + "----------\n" + "- audio_input (numpy.ndarray): 1-D (mono) or 2-D (channels, samples) block\n" + " of input audio. Any memory layout is accepted.\n" + "- output_samples (int, optional): number of output samples to produce for\n" + " this block.\n\n" + "Returns:\n" + "----------\n" + "- numpy.ndarray: this block's stretched or pitch-shifted output.") + .def("flush", &Stretch::flush, + "output_samples"_a, + "Drain the remaining output with no further input, e.g. at the end of a\n" + "stream driven through processBlock(). Does not reset the processor.") ; // .def("setFreqMap", &Stretch::setFreqMap, // "inputToOutput"_a) // TODO: implement custom frequency mapping diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..78e2eb5 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,241 @@ +import hashlib + +import numpy as np +import python_stretch as m + + +SR = 44100.0 + + +def _aligned_residual(a, b, max_lag=64): + """Align two 1-D signals within +/- max_lag samples (by lowest + residual energy), then return the residual energy of (a - b) at that + alignment, normalized by b's energy. + + 0 means identical. A max-sample-to-sample-step metric can score a + signal that has been corrupted every other block the same as a clean + one (both can be dominated by one loud transient elsewhere in the + signal) -- this residual-energy metric doesn't have that blind spot, + because it compares the whole signal, not just its single worst jump. + """ + a = np.asarray(a, dtype=np.float64) + b = np.asarray(b, dtype=np.float64) + n = min(len(a), len(b)) - max_lag + best_lag, best_score = 0, None + for lag in range(-max_lag, max_lag + 1): + seg_a = a[lag:lag + n] if lag >= 0 else a[:n] + seg_b = b[:n] if lag >= 0 else b[-lag:-lag + n] + score = np.sum((seg_a - seg_b) ** 2) + if best_score is None or score < best_score: + best_score, best_lag = score, lag + seg_a = a[best_lag:best_lag + n] if best_lag >= 0 else a[:n] + seg_b = b[:n] if best_lag >= 0 else b[-best_lag:-best_lag + n] + residual_energy = np.sum((seg_a - seg_b) ** 2) + reference_energy = np.sum(seg_b ** 2) + return residual_energy / reference_energy if reference_energy > 0 else float("inf") + + +# process()'s behaviour must not change by one sample. These digests were +# captured by running the SAME configurations below against the +# unmodified build (before processBlock()/seek()/flush() were added, +# process() itself untouched). If process() ever produces different +# output for these inputs, one of these will fail -- if that's ever a +# deliberate change, these digests are the thing to regenerate, and only +# that. +EXPECTED_DIGESTS = { + "mono_tf1_notranspose": ("33fcf21ef375323523575dc391cdf10cdcbacc5e2f5986ef214f3d6658138240", (1, 22050)), + "mono_tf0_75_notranspose": ("17f0fcc51bc4d5018c0b683f7fe979723efaf8bfc6258ae2667da7e372518d30", (1, 29400)), + "mono_tf1_transpose12": ("0f02302923cc64b6e02f35f492a573b7729dedaca4d6faa01e7f8788fe3c37d8", (1, 22050)), + "stereo_tf1_notranspose": ("163eba351a24455dcac3eb3c5ad32055f50b860c7387eace9052f738e79dbcdd", (2, 22050)), + "stereo_tf1_5_notranspose": ("6e0c94926b212cab6b2d055f0e7359939404986d93098af58aa58204ac07f63f", (2, 14700)), + "stereo_tf1_transpose_minus7": ("e65634cf4889030ceb291cbd42cac28701f8263be9f4b1918f6d94295d2d9768", (2, 22050)), +} + +# (channels, n_samples, time_factor, transpose_semitones, seed), matched +# to the names above by position. +_CONFIGS = [ + ("mono_tf1_notranspose", 1, 22050, 1.0, 0.0, 0), + ("mono_tf0_75_notranspose", 1, 22050, 0.75, 0.0, 1), + ("mono_tf1_transpose12", 1, 22050, 1.0, 12.0, 2), + ("stereo_tf1_notranspose", 2, 22050, 1.0, 0.0, 3), + ("stereo_tf1_5_notranspose", 2, 22050, 1.5, 0.0, 4), + ("stereo_tf1_transpose_minus7", 2, 22050, 1.0, -7.0, 5), +] + + +def test_process_output_is_unchanged_backwards_compatible(): + # process() isn't touched by this change at all -- this test pins its + # existing behaviour rather than exercising anything new, so there's + # no red phase to show for it. + for name, channels, n, tf, semitones, seed in _CONFIGS: + rng = np.random.default_rng(seed) + audio = rng.normal(0, 0.1, size=(channels, n)).astype(np.float32) + s = m.Signalsmith.Stretch(seed=0) + s.preset(channels, SR) + s.setTimeFactor(tf) + if semitones != 0.0: + s.setTransposeSemitones(semitones) + out = s.process(audio) + expected_digest, expected_shape = EXPECTED_DIGESTS[name] + assert out.shape == expected_shape, name + assert hashlib.sha256(out.tobytes()).hexdigest() == expected_digest, name + + +def test_streaming_blocks_match_a_one_shot_reference(): + rng = np.random.default_rng(10) + total = rng.normal(0, 0.1, size=(1, 44100)).astype(np.float32) + chunk = 4410 + + reference = m.Signalsmith.Stretch(seed=0) + reference.preset(1, SR) + ref_out = reference.processBlock(total, total.shape[1]) + + streamed = m.Signalsmith.Stretch(seed=0) + streamed.preset(1, SR) + parts = [ + streamed.processBlock(total[:, i:i + chunk], chunk) + for i in range(0, total.shape[1], chunk) + ] + streamed_out = np.concatenate(parts, axis=1) + + # In this case they're not just close, they're bit-for-bit identical -- + # the residual/energy check below is what a real (non-integer) block + # size or time-stretch ratio would need, kept here for the negative + # control that follows. + assert np.array_equal(ref_out, streamed_out) + clean_score = _aligned_residual(streamed_out[0], ref_out[0]) + assert clean_score < 1e-9 + + # Negative control: corrupt every other block by inverting its sign, + # and check the metric actually reacts. A metric that can't tell a + # corrupted stream from a clean one isn't testing anything. + corrupted = m.Signalsmith.Stretch(seed=0) + corrupted.preset(1, SR) + parts = [] + for idx, i in enumerate(range(0, total.shape[1], chunk)): + block_out = corrupted.processBlock(total[:, i:i + chunk], chunk) + parts.append(-block_out if idx % 2 else block_out) + corrupted_out = np.concatenate(parts, axis=1) + + corrupted_score = _aligned_residual(corrupted_out[0], ref_out[0]) + assert corrupted_score > 1.0 + assert corrupted_score > 1000 * max(clean_score, 1e-12) + + +def test_persistent_object_differs_from_a_fresh_object_per_block(): + # process() resets the processor on every call, so a persistent + # object driven in blocks and a fresh object built per block give + # bit-identical output through process() -- that's the defect this + # change exists to fix (see tests/test_pitch_shift.py and + # tests/test_time_stretch.py, which never drive process() in blocks + # at all, since there was no point: every call was already a fresh + # start). processBlock() doesn't reset, so state should carry over, + # and a persistent object should behave differently from a fresh one + # per block. + rng = np.random.default_rng(11) + total = rng.normal(0, 0.1, size=(1, 44100)).astype(np.float32) + chunk = 4410 + + persistent = m.Signalsmith.Stretch(seed=0) + persistent.preset(1, SR) + parts = [ + persistent.processBlock(total[:, i:i + chunk], chunk) + for i in range(0, total.shape[1], chunk) + ] + out_persistent = np.concatenate(parts, axis=1) + + parts = [] + for i in range(0, total.shape[1], chunk): + fresh = m.Signalsmith.Stretch(seed=0) + fresh.preset(1, SR) + parts.append(fresh.processBlock(total[:, i:i + chunk], chunk)) + out_fresh = np.concatenate(parts, axis=1) + + assert not np.array_equal(out_persistent, out_fresh) + + +def test_streaming_is_deterministic_across_repeated_runs(): + def run(): + rng = np.random.default_rng(12) + total = rng.normal(0, 0.1, size=(2, 22050)).astype(np.float32) + s = m.Signalsmith.Stretch(seed=0) + s.preset(2, SR) + chunk = 2205 + parts = [ + s.processBlock(total[:, i:i + chunk], chunk) + for i in range(0, total.shape[1], chunk) + ] + return np.concatenate(parts, axis=1) + + assert np.array_equal(run(), run()) + + +def test_time_factor_change_between_blocks_takes_effect_and_keeps_pitch(): + freq = 440.0 + n = 44100 + t = np.arange(n) / SR + tone = (0.2 * np.sin(2 * np.pi * freq * t)).astype(np.float32).reshape(1, -1) + + s = m.Signalsmith.Stretch(seed=0) + s.preset(1, SR) + + chunk = 4410 + half = n // 2 + parts = [ + s.processBlock(tone[:, i:i + chunk], chunk) + for i in range(0, half, chunk) + ] + # Second half: ask for double-length output per block, i.e. half speed. + parts += [ + s.processBlock(tone[:, i:i + chunk], chunk * 2) + for i in range(half, n, chunk) + ] + out = np.concatenate(parts, axis=1) + + assert out.shape[1] == half + (n - half) * 2 + + def dominant_freq(x): + window = np.hanning(len(x)) + spectrum = np.abs(np.fft.rfft(x * window)) + freqs = np.fft.rfftfreq(len(x), d=1 / SR) + return freqs[np.argmax(spectrum)] + + # 44.1kHz / 22050 samples gives ~2Hz FFT bins; 5Hz is a generous + # margin either side of the true 440Hz tone. + assert abs(dominant_freq(out[0, :half]) - freq) < 5.0 + assert abs(dominant_freq(out[0, half:]) - freq) < 5.0 + + +def test_many_blocks_do_not_crash(): + rng = np.random.default_rng(13) + s = m.Signalsmith.Stretch(seed=0) + s.preset(1, SR) + chunk = 512 + for _ in range(20000): + block = rng.normal(0, 0.1, size=(1, chunk)).astype(np.float32) + out = s.processBlock(block, chunk) + assert out.shape == (1, chunk) + + +def test_seek_and_flush_do_not_crash_and_return_correct_shapes(): + rng = np.random.default_rng(14) + total = rng.normal(0, 0.1, size=(2, 22050)).astype(np.float32) + + s = m.Signalsmith.Stretch(seed=0) + s.preset(2, SR) + s.seek(total[:, :4410], 1.0) + out = s.processBlock(total[:, 4410:8820], 4410) + assert out.shape == (2, 4410) + tail = s.flush(s.outputLatency()) + assert tail.shape == (2, s.outputLatency()) + + +def test_processblock_before_configure_raises_a_clear_error(): + s = m.Signalsmith.Stretch(seed=0) + audio = np.zeros((1, 100), dtype=np.float32) + try: + s.processBlock(audio, 100) + except RuntimeError as e: + assert "preset" in str(e) or "configure" in str(e) + else: + raise AssertionError("expected a RuntimeError before preset()/configure()") From 19ca42aa54f1161bf0bad2504073c89588dfe3d3 Mon Sep 17 00:00:00 2001 From: sanerdemirel <300525555+sanerdemirel@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:52:25 +0200 Subject: [PATCH 2/4] Cover processBlock's default output length in a test --- tests/test_streaming.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 78e2eb5..3d4f17e 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -230,6 +230,21 @@ def test_seek_and_flush_do_not_crash_and_return_correct_shapes(): assert tail.shape == (2, s.outputLatency()) +def test_processblock_output_samples_defaults_to_timefactor(): + # Same default as process(): output length = round(input length / + # timeFactor), used whenever output_samples isn't given. + s = m.Signalsmith.Stretch(seed=0) + s.preset(1, SR) + s.setTimeFactor(2.0) + block = np.zeros((1, 1000), dtype=np.float32) + + out_explicit_none = s.processBlock(block, None) + assert out_explicit_none.shape == (1, 500) + + out_omitted = s.processBlock(block) + assert out_omitted.shape == (1, 500) + + def test_processblock_before_configure_raises_a_clear_error(): s = m.Signalsmith.Stretch(seed=0) audio = np.zeros((1, 100), dtype=np.float32) From 61e97dae85acfeb027d789465d8b302bcd6734c1 Mon Sep 17 00:00:00 2001 From: sanerdemirel <300525555+sanerdemirel@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:52:25 +0200 Subject: [PATCH 3/4] Restore accidental whitespace change on an untouched comment line --- src/signalsmith-bindings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/signalsmith-bindings.cpp b/src/signalsmith-bindings.cpp index efc9f7b..063babb 100644 --- a/src/signalsmith-bindings.cpp +++ b/src/signalsmith-bindings.cpp @@ -464,7 +464,7 @@ NB_MODULE(Signalsmith, m) { "----------\n" "- timeFactor (float): Factor by which time is stretched or compressed (e.g., 0.5 slows down by half, 2.0 doubles speed).") - // PROCESSING + // PROCESSING .def("process", &Stretch::process, "audio_input"_a, "Process an input audio buffer and return the stretched or pitch-shifted output.\n\n" From d8f78c780b659828344e50191692eb7609b89323 Mon Sep 17 00:00:00 2001 From: sanerdemirel <300525555+sanerdemirel@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:52:26 +0200 Subject: [PATCH 4/4] Document that a short flush() folds its tail back instead of truncating 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. --- src/signalsmith-bindings.cpp | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/signalsmith-bindings.cpp b/src/signalsmith-bindings.cpp index 063babb..d73c878 100644 --- a/src/signalsmith-bindings.cpp +++ b/src/signalsmith-bindings.cpp @@ -501,7 +501,31 @@ NB_MODULE(Signalsmith, m) { .def("flush", &Stretch::flush, "output_samples"_a, "Drain the remaining output with no further input, e.g. at the end of a\n" - "stream driven through processBlock(). Does not reset the processor.") + "stream driven through processBlock(). Does not reset the processor.\n\n" + "IMPORTANT -- a short flush does not truncate, it folds:\n" + "----------\n" + "Ask for exactly the number of samples you intend to keep. Requesting\n" + "fewer than the natural tail length gives you DIFFERENT audio, not\n" + "shorter audio.\n\n" + "The natural tail is blockSamples() samples, which is also exactly\n" + "inputLatency() + outputLatency(). Below that length the underlying\n" + "library takes the part that would not fit, reverses it in time and\n" + "SUBTRACTS it onto the end of the buffer you asked for, so a short\n" + "flush carries more energy than the corresponding prefix of a full\n" + "one. This is deliberate anti-truncation behaviour in the library\n" + "itself, not a quirk of this binding.\n\n" + "The practical consequence is easy to get wrong: flushing generously\n" + "and slicing the result does NOT give the same audio as flushing\n" + "exactly. flush(4 * n)[:n] and flush(n) differ. At or above the\n" + "natural tail the output is prefix-stable and slicing is safe.\n\n" + "Parameters:\n" + "----------\n" + "- output_samples (int): number of output samples to drain. Must be\n" + " >= 0. See the note above before choosing a value below\n" + " blockSamples().\n\n" + "Returns:\n" + "----------\n" + "- numpy.ndarray: the drained tail.") ; // .def("setFreqMap", &Stretch::setFreqMap, // "inputToOutput"_a) // TODO: implement custom frequency mapping