Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions src/signalsmith-bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#include <nanobind/ndarray.h>
#include "stretch/signalsmith-stretch.h"

#include <stdexcept>

namespace nb = nanobind;

using namespace nb::literals;
Expand Down Expand Up @@ -151,12 +153,26 @@ struct Stretch{
// ====================

// === Processing ===
nb::ndarray<nb::numpy, float, nb::ndim<2>> process(nb::ndarray<nb::numpy, float, nb::ndim<2>> audio_input) {
auto inData = audio_input.data();
nb::ndarray<nb::numpy, float, nb::ndim<2>> process(nb::ndarray<nb::numpy, float> 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();
Expand All @@ -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)
Expand Down Expand Up @@ -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.")
Expand Down
68 changes: 68 additions & 0 deletions tests/test_noncontiguous_input.py
Original file line number Diff line number Diff line change
@@ -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)