Repository: librosa/librosa

Issue:
Expected number of samples from load()?

When duration*sample_rate is not an integer, it seems that the behavior of __soundfile_load and __audioread_load are different with respect to how many samples will be returned: 

__soundfile_load performs int(duration*sample), ie a 'floor' operation:

https://github.com/librosa/librosa/blob/f21bda6b9fd7d9f1276749862247a6daf59e792d/librosa/core/audio.py#LL215C13-L215C13

while __audioread_load performs int(np.round(duration*sample)): 

https://github.com/librosa/librosa/blob/f21bda6b9fd7d9f1276749862247a6daf59e792d/librosa/core/audio.py#LL247C9-L247C9

Is this behavior intentional? 

Furthermore, neither of these behaviors seem to explain observed results: (sample rate is 22050) 
```
samples, sr = librosa.load(path=audio_path,offset=0,duration=5/22050) #5 samples
samples, sr = librosa.load(path=audio_path,offset=0,duration=5.1/22050) #5 samples
samples, sr = librosa.load(path=audio_path,offset=0,duration=5.9/22050) #6 samples
samples, sr = librosa.load(path=audio_path,offset=0,duration=5.51/22050) #5 samples (?!)
```
Is this a floating point precision issue? `int(np.round(5.51/22050*22050))` gives me 6, not 5, so I don't know why this last line gives 5 samples. 




Discussion:
I think there are a few things going on here.

TLDR: yes, this does appear to be somewhat inconsistent, and not intentional.  However, the lines you're linking to refer to the `offset` calculation, which will be bypassed in your example.

In your example code, it's not obvious or controlled whether you're hitting the audioread or soundfile code path, so I can't say for certain why you're getting different results in the last two lines.  You also state that the sample rate is 22050, but is that guaranteed?  If the native rate of the file is something else, then you may hit rounding errors in resampling that could be producing the different length behaviors.

At this point, my preference would be for the floor style, especially since the long-term plan is to deprecate audioread anyway #1456 .  This would mean that the correct lengths for your examples should be 5 samples in all cases, but only if we assume that the native sample rate is 22050.
Flagging this for 0.10.1 to implement flooring consistently across decoders.
Looking into this one today.  We also have a bit of disagreement in how `duration` and `offset` are handled by our signal generators.  Tone and chirp take `int(ceil(...))`; soundfile load and stream take floor, audioread and clicks take round.

I propose that we adjust all of these to use floor instead.  This way, the `duration` parameter would be consistently* interpreted across all functions.

*: not exactly.  The load functions apply truncation before sample rate conversion, so there's still a chance for a bit of rounding discrepancies depending on which resampler is used.  I don't think there's anything we can do about that, short of truncating after resampling.
.. and for the sake of consistency, `time_to_samples` uses integer casting, which is implicitly flooring `time * sr`.

Resolving patch:
diff --git a/librosa/core/audio.py b/librosa/core/audio.py
--- a/librosa/core/audio.py
+++ b/librosa/core/audio.py
@@ -242,12 +242,12 @@ def __audioread_load(path, offset, duration, dtype: DTypeLike):
         sr_native = input_file.samplerate
         n_channels = input_file.channels
 
-        s_start = int(np.round(sr_native * offset)) * n_channels
+        s_start = int(sr_native * offset) * n_channels
 
         if duration is None:
             s_end = np.inf
         else:
-            s_end = s_start + (int(np.round(sr_native * duration)) * n_channels)
+            s_end = s_start + (int(sr_native * duration) * n_channels)
 
         n = 0
 
@@ -1384,7 +1384,7 @@ def clicks(
 
         angular_freq = 2 * np.pi * click_freq / float(sr)
 
-        click = np.logspace(0, -10, num=int(np.round(sr * click_duration)), base=2.0)
+        click = np.logspace(0, -10, num=int(sr * click_duration), base=2.0)
 
         click *= np.sin(angular_freq * np.arange(len(click)))
 
@@ -1480,7 +1480,7 @@ def tone(
     if length is None:
         if duration is None:
             raise ParameterError('either "length" or "duration" must be provided')
-        length = int(np.ceil(duration * sr))
+        length = int(duration * sr)
 
     if phi is None:
         phi = -np.pi * 0.5
@@ -1593,7 +1593,7 @@ def chirp(
 
     method = "linear" if linear else "logarithmic"
     y: np.ndarray = scipy.signal.chirp(
-        np.arange(int(np.ceil(duration * sr))) / sr,
+        np.arange(int(duration * sr)) / sr,
         fmin,
         duration,
         fmax,


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1590,7 +1590,7 @@ def test_tone(frequency, sr, length, duration, phi):
     if length is not None:
         assert len(y) == length
     else:
-        assert len(y) == np.ceil(duration * sr)
+        assert len(y) == int(duration * sr)
 
 
 @pytest.mark.xfail(raises=librosa.ParameterError)
@@ -1621,7 +1621,7 @@ def test_chirp(fmin, fmax, sr, length, duration, linear, phi):
     if length is not None:
         assert len(y) == length
     else:
-        assert len(y) == np.ceil(duration * sr)
+        assert len(y) == int(duration * sr)
 
 
 @pytest.mark.xfail(raises=librosa.ParameterError)