diff --git a/src/signalsmith-bindings.cpp b/src/signalsmith-bindings.cpp index c7ca08a..dfdea4f 100644 --- a/src/signalsmith-bindings.cpp +++ b/src/signalsmith-bindings.cpp @@ -2,6 +2,8 @@ #include #include "stretch/signalsmith-stretch.h" +#include + namespace nb = nanobind; using namespace nb::literals; @@ -151,12 +153,26 @@ struct Stretch{ // ==================== // === Processing === - nb::ndarray> process(nb::ndarray> audio_input) { - auto inData = audio_input.data(); + nb::ndarray> process(nb::ndarray audio_input) { + // Accept 1-D (mono) or 2-D (channels, samples) input, and read + // every sample through its own stride instead of assuming the + // array is C-contiguous. A transposed array -- what + // librosa.load(..., mono=False) commonly returns -- is a view + // with the channel and sample strides swapped relative to a + // freshly-allocated one, and copying through it as if it were + // still contiguous is what produced the corrupted output in + // issue #3. + size_t ndim = audio_input.ndim(); + if (ndim != 1 && ndim != 2) { + throw std::invalid_argument("audio_input must be 1-D (mono) or 2-D (channels, samples)"); + } + + size_t numChannels = (ndim == 1) ? 1 : audio_input.shape(0); + size_t inputLength = (ndim == 1) ? audio_input.shape(0) : audio_input.shape(1); + int64_t channelStride = (ndim == 1) ? 0 : audio_input.stride(0); + int64_t sampleStride = (ndim == 1) ? audio_input.stride(0) : audio_input.stride(1); + const float* inData = audio_input.data(); - size_t numChannels = audio_input.shape(0); - size_t inputLength = audio_input.shape(1); - // Padding for latency size_t paddedInputLength = inputLength + stretch_.inputLatency(); int tailSamples = stretch_.outputLatency(); @@ -166,15 +182,22 @@ struct Stretch{ // Allocate and initialize buffers float** inputChannels = new float*[numChannels]; float** outputChannels = new float*[numChannels]; - + for (size_t i = 0; i < numChannels; ++i) { inputChannels[i] = new float[paddedInputLength](); outputChannels[i] = new float[paddedOutputLength](); } - // Copy from inData to inputChannels - for (size_t i = 0; i < numChannels; ++i) { - std::copy(inData + i*inputLength , inData + (i+1)*inputLength , inputChannels[i]); + // Copy from inData to inputChannels, following the input's own + // strides. This buffer is always freshly allocated and this + // copy always ran before, contiguous or not, so reading through + // strides here costs nothing extra over the old std::copy -- + // it just also gives the right answer when the input isn't + // C-contiguous. + for (size_t c = 0; c < numChannels; ++c) { + for (size_t i = 0; i < inputLength; ++i) { + inputChannels[c][i] = inData[c * channelStride + i * sampleStride]; + } } // Wrap input/output channel-buffer with Buffer class (for offset reading/writing) @@ -297,7 +320,10 @@ NB_MODULE(Signalsmith, m) { "Process an input audio buffer and return the stretched or pitch-shifted output.\n\n" "Parameters:\n" "----------\n" - "- audio_input (numpy.ndarray): Input audio buffer to be processed.\n\n" + "- audio_input (numpy.ndarray): 1-D (mono) or 2-D (channels, samples) input\n" + " audio buffer to be processed. Any memory layout is accepted, including\n" + " non-contiguous arrays such as a transposed librosa.load(..., mono=False)\n" + " result.\n\n" "Returns:\n" "----------\n" "- numpy.ndarray: Stretched or pitch-shifted output audio buffer.") diff --git a/tests/test_noncontiguous_input.py b/tests/test_noncontiguous_input.py new file mode 100644 index 0000000..2116646 --- /dev/null +++ b/tests/test_noncontiguous_input.py @@ -0,0 +1,68 @@ +import python_stretch as m +import numpy as np + + +def test_non_contiguous_stereo_input_matches_contiguous(): + # This is the layout librosa.load(..., mono=False) commonly hands you: + # a (samples, channels) array transposed to (channels, samples), which + # is a view, not a copy, so it is not C-contiguous. See issue #3. + rng = np.random.default_rng(0) + audio = rng.normal(0, 0.1, size=(44100, 2)).astype(np.float32) + audio_noncontig = audio.T + assert not audio_noncontig.flags["C_CONTIGUOUS"] + + audio_contig = np.ascontiguousarray(audio_noncontig) + assert np.array_equal(audio_noncontig, audio_contig) + + ps_contig = m.Signalsmith.Stretch(seed=0) + ps_contig.preset(2, 44100.0) + ps_contig.setTimeFactor(1.0) + out_contig = ps_contig.process(audio_contig) + + ps_noncontig = m.Signalsmith.Stretch(seed=0) + ps_noncontig.preset(2, 44100.0) + ps_noncontig.setTimeFactor(1.0) + out_noncontig = ps_noncontig.process(audio_noncontig) + + assert np.array_equal(out_contig, out_noncontig) + + +def test_non_contiguous_stereo_from_slicing_matches_contiguous(): + # A second, unrelated way to end up non-contiguous: taking every other + # sample. Strides differ from the transpose case above, so this checks + # the fix isn't specific to one stride pattern. + rng = np.random.default_rng(1) + audio = rng.normal(0, 0.1, size=(2, 88200)).astype(np.float32) + audio_noncontig = audio[:, ::2] + assert not audio_noncontig.flags["C_CONTIGUOUS"] + + audio_contig = np.ascontiguousarray(audio_noncontig) + + ps_contig = m.Signalsmith.Stretch(seed=0) + ps_contig.preset(2, 44100.0) + ps_contig.setTimeFactor(1.0) + out_contig = ps_contig.process(audio_contig) + + ps_noncontig = m.Signalsmith.Stretch(seed=0) + ps_noncontig.preset(2, 44100.0) + ps_noncontig.setTimeFactor(1.0) + out_noncontig = ps_noncontig.process(audio_noncontig) + + assert np.array_equal(out_contig, out_noncontig) + + +def test_mono_1d_input_is_promoted_to_a_single_channel(): + # process() previously required a 2-D array and raised a TypeError for + # a 1-D one. The maintainer's proposed fix promotes a 1-D input to 2-D, + # so this is new behaviour, not a case that already worked -- it is + # exercised here so the promotion path doesn't silently regress later. + rng = np.random.default_rng(2) + audio = rng.normal(0, 0.1, size=(44100,)).astype(np.float32) + assert audio.ndim == 1 + + ps = m.Signalsmith.Stretch(seed=0) + ps.preset(1, 44100.0) + ps.setTimeFactor(1.0) + out = ps.process(audio) + + assert out.shape == (1, 44100)