Skip to content
Open
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,18 @@ import soundfile as sf
import loudness

audio, sr = sf.read("audio.wav", dtype="float32") # shape (samples, channels)

# Get overall integrated loudness
lufs = loudness.integrated_loudness(audio, sr)
print(f"{lufs:.2f} LUFS")

# Get loudness per window (e.g., to analyze loudness over time)
lufs_per_window = loudness.loudness_per_window(audio, sr, window_duration_sec=0.5)
print(f"Windows: {lufs_per_window}")

# Calculate percentage above threshold
percentage_loud = (lufs_per_window > -30).mean() * 100
print(f"{percentage_loud:.1f}% of windows are above -30 LUFS")
```

## Performance
Expand Down
102 changes: 102 additions & 0 deletions src/loudness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,67 @@ double integrated_loudness(
return meter.loudnessGlobal();
}

/**
* Compute integrated loudness (LUFS) for non-overlapping windows of audio.
*
* samples: float32 NumPy array with shape (samples,) or (samples, channels)
* sample_rate: sample rate in Hz
* window_duration_sec: duration of each window in seconds
*
* Returns NumPy array of LUFS values, one per window
*/
py::array_t<double> loudness_per_window(
const py::array_t<float,
py::array::c_style | py::array::forcecast> &samples,
int sample_rate,
double window_duration_sec)
{
const auto buf = samples.request();

if (buf.ndim != 1 && buf.ndim != 2)
throw std::runtime_error(
"samples must be 1D (mono) or 2D (samples, channels)");

if (window_duration_sec < kMinDurationSec)
throw py::value_error("window_duration_sec must be at least "
+ std::to_string(kMinDurationSec) + " seconds");

const std::size_t total_frames = static_cast<std::size_t>(buf.shape[0]);
const unsigned int channels =
(buf.ndim == 2) ? static_cast<unsigned int>(buf.shape[1]) : 1U;
const std::size_t window_frames = static_cast<std::size_t>(window_duration_sec * sample_rate);

const float *data = static_cast<const float *>(buf.ptr);

const std::size_t num_windows = total_frames / window_frames;

if (num_windows == 0)
throw py::value_error("audio too short: need at least "
+ std::to_string(window_duration_sec * 1000) + " ms, got "
+ std::to_string(static_cast<double>(total_frames) / sample_rate * 1000) + " ms");

std::vector<double> lufs_values;
lufs_values.reserve(num_windows);

const std::size_t stride = (buf.ndim == 2) ? channels : 1;

loudness::Meter<Mode::EBU_I | Mode::Histogram> meter(
loudness::NumChannels(channels),
loudness::Samplerate(static_cast<long>(sample_rate)));

for (std::size_t i = 0; i < num_windows; ++i) {
meter.reset();

const std::size_t start_frame = i * window_frames;
const float *window_data = data + (start_frame * stride);

meter.addFrames(window_data, window_frames);
lufs_values.push_back(meter.loudnessGlobal());
}

return py::array_t<double>(lufs_values.size(), lufs_values.data());
}

PYBIND11_MODULE(loudness, m)
{
m.doc() = "Python bindings for libloudness, for calculating integrated loudness in LUFS (ITU BS.1770 / EBU R 128).";
Expand All @@ -71,5 +132,46 @@ Raises
------
ValueError
If the audio is too short (<400 ms), has too many channels (>64), or the sample_rate is outside the supported range (16-2822400 Hz).
)pbdoc");

m.def("loudness_per_window", &loudness_per_window,
py::arg("samples"), py::arg("sample_rate"), py::arg("window_duration_sec"),
R"pbdoc(
Compute EBU‑R128 integrated loudness (LUFS) for non-overlapping windows.

Splits the audio into non-overlapping windows of the specified duration and
computes the integrated loudness for each window independently.

Parameters
----------
samples: numpy.ndarray float32
Mono 1D array (n_samples) or
interleaved 2D array (n_samples, n_channels).
sample_rate: int
Sample rate in hertz.
window_duration_sec: float
Duration of each window in seconds (must be >= 0.4).

Returns
-------
numpy.ndarray float64
Array of integrated loudness values in LUFS, one per window.
Incomplete final windows are discarded.

Raises
------
ValueError
If window_duration_sec is too short (<400 ms), the audio is too short
for at least one window, has too many channels (>64), or the sample_rate
is outside the supported range (16-2822400 Hz).

Examples
--------
>>> import soundfile as sf
>>> import loudness
>>> audio, sr = sf.read("audio.wav", dtype="float32")
>>> lufs_per_window = loudness.loudness_per_window(audio, sr, window_duration_sec=0.5)
>>> percentage_loud = (lufs_per_window > -30).mean() * 100
>>> print(f"{percentage_loud:.1f}% of windows are above -30 LUFS")
)pbdoc");
}
51 changes: 51 additions & 0 deletions tests/test_integrated_loudness.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,54 @@ def test_too_many_channels():
samples = np.zeros((25000, 65), dtype=np.float32)
with pytest.raises(ValueError):
loudness.integrated_loudness(samples, 44100)


def test_loudness_per_window_mono():
audio, sample_rate = soundfile.read(
TEST_FIXTURES_PATH / "p286_011.wav", dtype=np.float32
)
assert audio.ndim == 1

window_duration = 0.5
lufs_per_window = loudness.loudness_per_window(audio, sample_rate, window_duration)

assert isinstance(lufs_per_window, np.ndarray)
assert lufs_per_window.dtype == np.float64

expected_num_windows = int(len(audio) / (sample_rate * window_duration))
assert len(lufs_per_window) == expected_num_windows

assert all(lufs >= -70 for lufs in lufs_per_window)


def test_loudness_per_window_stereo():
audio, sample_rate = soundfile.read(
TEST_FIXTURES_PATH / "perfect-alley1.ogg", dtype=np.float32
)

window_duration = 1.0
lufs_per_window = loudness.loudness_per_window(audio, sample_rate, window_duration)

assert isinstance(lufs_per_window, np.ndarray)
expected_num_windows = int(len(audio) / (sample_rate * window_duration))
assert len(lufs_per_window) == expected_num_windows


def test_loudness_per_window_too_short():
sample_rate = 44100
duration = 0.5
num_samples = int(sample_rate * duration)
samples = np.ones((num_samples,), dtype=np.float32)

window_duration = 1.0
with pytest.raises(ValueError):
loudness.loudness_per_window(samples, sample_rate, window_duration)


def test_loudness_per_window_window_too_short():
sample_rate = 44100
num_samples = int(sample_rate * 2.0)
samples = np.ones((num_samples,), dtype=np.float32)

with pytest.raises(ValueError):
loudness.loudness_per_window(samples, sample_rate, window_duration_sec=0.3)