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..d73c878 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 @@ -301,6 +474,58 @@ 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.\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 diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 0000000..3d4f17e --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,256 @@ +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_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) + 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()")