Restored recommendations based on acoustic similarities (using musicnn), fixes #301

This commit is contained in:
emeric
2026-06-02 08:32:43 +02:00
parent 1524106124
commit eb7f65878f
227 changed files with 10324 additions and 4673 deletions
@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include <array>
#include <fstream>
#include <span>
#include <string>
#include "core/XxHash3.hpp"
#if LMS_HAVE_ONNX_RUNTIME
#include "musicnn/MusicNNEmbeddingExtractor.hpp"
#endif
namespace lms::audio
{
bool canExtractMusicNNEmbeddings()
{
#if LMS_HAVE_ONNX_RUNTIME
return true;
#else
return false;
#endif
}
std::unique_ptr<IMusicNNEmbeddingExtractor> createMusicNNEmbeddingExtractor([[maybe_unused]] const std::filesystem::path& modelPath, std::size_t maxPatchCount)
{
#if LMS_HAVE_ONNX_RUNTIME
return std::make_unique<musicnn::MusicNNEmbeddingExtractor>(modelPath, maxPatchCount);
#else
return {};
#endif
}
std::string getMusicNNModelIdentifier(const std::filesystem::path& modelPath)
{
std::ifstream file{ modelPath, std::ios::binary };
if (!file)
return {};
core::XxHash3_64 hasher;
constexpr std::size_t readBufSize{ 65536 };
std::array<char, readBufSize> buf{};
while (file.read(buf.data(), buf.size()) || file.gcount() > 0)
hasher.update(std::as_bytes(std::span{ buf.data(), static_cast<std::size_t>(file.gcount()) }));
if (!file.eof())
return {};
return std::to_string(hasher.digest());
}
} // namespace lms::audio
+7
View File
@@ -38,4 +38,11 @@ namespace lms::audio
throw Exception{ "Unhandled sample type" };
}
namespace helpers
{
std::size_t sampleCountToByteCount(std::size_t sampleCount, PcmSampleType sampleType, unsigned channelCount)
{
return sampleCount * audio::getSampleSize(sampleType) * channelCount;
}
} // namespace helpers
} // namespace lms::audio
@@ -0,0 +1,143 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "MelFilterBank.hpp"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <numeric>
#include "audio/Exception.hpp"
namespace lms::audio::features
{
float freqToMel(float freq)
{
return 2595.F * std::log10(1.0F + freq / 700.F);
}
float melToFreq(float mel)
{
return 700.F * (std::pow(10.F, mel / 2595.F) - 1.F);
}
MelFilterBank::MelFilterBank(std::vector<Filter>&& _filters, std::size_t binCount)
: _filters{ std::move(_filters) }
, _binCount{ binCount }
{
}
const MelFilterBank::Filter& MelFilterBank::getFilter(std::size_t m) const
{
return _filters.at(m);
}
std::size_t MelFilterBank::getFilterCount() const
{
return _filters.size();
}
std::size_t MelFilterBank::getBinCount() const
{
return _binCount;
}
float MelFilterBank::computeEnergy(std::size_t m, std::span<const float> input) const
{
if (input.size() != _binCount)
throw Exception{ "Input size must be equal to the number of bins" };
const auto& filter{ _filters.at(m) };
assert(filter.leftBinIndex + filter.weights.size() <= input.size());
float energy{};
std::size_t bin{ filter.leftBinIndex };
for (std::size_t i{}, n = filter.weights.size(); i < n; ++i, ++bin)
energy += input[bin] * filter.weights[i];
return energy;
}
MelFilterBank computeMelFilterBank(std::size_t nfft, std::size_t sampleRate, std::size_t filterCount, float fMin, float fMax)
{
const float nyquist{ sampleRate / 2.F };
const float effectiveFMin{ (fMin <= 0.F) ? 0.F : fMin };
const float effectiveFMax{ (fMax <= 0.F || fMax > nyquist) ? nyquist : fMax };
const float melMin{ freqToMel(effectiveFMin) };
const float melMax{ freqToMel(effectiveFMax) };
// 1. mel points
std::vector<float> melPoints(filterCount + 2);
for (std::size_t i{}; i < melPoints.size(); ++i)
melPoints[i] = melMin + i * (melMax - melMin) / (filterCount + 1);
// 2. mel -> Hz
std::vector<float> freqs(filterCount + 2);
std::transform(melPoints.begin(), melPoints.end(), freqs.begin(), melToFreq);
// 3. Hz -> bins
std::vector<size_t> bins(filterCount + 2);
for (std::size_t i{}; i < bins.size(); ++i)
bins[i] = static_cast<size_t>(std::floor(nfft * freqs[i] / sampleRate));
// 4. fix duplicates
for (std::size_t i{ 1 }; i < bins.size(); ++i)
{
if (bins[i] <= bins[i - 1])
bins[i] = bins[i - 1] + 1;
}
// 5. build filters
const std::size_t binCount{ nfft / 2 + 1 };
std::vector<MelFilterBank::Filter> filters{ filterCount };
for (std::size_t m{}; m < filterCount; ++m)
{
const std::size_t left{ bins[m] };
const std::size_t center{ bins[m + 1] };
const std::size_t right{ bins[m + 2] };
std::vector<float> filterWeights;
filterWeights.reserve(right - left);
// rising edge of the triangle
for (std::size_t k{ left }; k < center; ++k)
filterWeights.push_back(float(k - left) / (center - left));
// falling edge of the triangle
for (std::size_t k{ center }; k < right; ++k)
filterWeights.push_back(float(right - k) / (right - center));
assert(filterWeights.size() == right - left);
// normalize the filter to sum = 1
const float sum{ std::accumulate(filterWeights.begin(), filterWeights.end(), 0.F) };
if (sum > 0.F)
{
for (float& weight : filterWeights)
weight /= sum;
}
filters[m] = MelFilterBank::Filter{ std::move(filterWeights), left };
}
return MelFilterBank{ std::move(filters), binCount };
}
} // namespace lms::audio::features
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <span>
#include <vector>
namespace lms::audio::features
{
float freqToMel(float freq);
float melToFreq(float mel);
struct MelFilterBank
{
struct Filter
{
std::vector<float> weights; // normalized so that the sum of weights equals 1.0
std::size_t leftBinIndex; // index of the leftmost FFT bin covered by the filter
};
// binCount is the number of FFT bins (nfft/2 + 1) that the filters can cover
MelFilterBank(std::vector<Filter>&& _filters, std::size_t binCount);
const Filter& getFilter(std::size_t m) const;
std::size_t getFilterCount() const;
std::size_t getBinCount() const;
float computeEnergy(std::size_t m, std::span<const float> input) const;
private:
const std::vector<Filter> _filters;
const std::size_t _binCount;
};
// Each filter covers a range of FFT bins and is normalized so that the sum of its weights equals 1.0
// Each filter stores only its non-zero triangular region (sparse representation)
// fMin/fMax: frequency range in Hz. Defaults (0.f, 0.f) span from 0 to Nyquist.
MelFilterBank computeMelFilterBank(size_t nfft, size_t sampleRate, size_t filterCount, float fMin = 0.F, float fMax = 0.F);
} // namespace lms::audio::features
+2 -2
View File
@@ -285,7 +285,7 @@ namespace lms::audio::ffmpeg
bool AudioFile::hasAttachedPictures() const
{
for (std::size_t i = 0; i < _context->nb_streams; ++i)
for (std::size_t i{}; i < _context->nb_streams; ++i)
{
if (_context->streams[i]->disposition & AV_DISPOSITION_ATTACHED_PIC)
return true;
@@ -304,7 +304,7 @@ namespace lms::audio::ffmpeg
{ AV_CODEC_ID_PPM, "image/x-portable-pixmap" },
};
for (std::size_t i = 0; i < _context->nb_streams; ++i)
for (std::size_t i{}; i < _context->nb_streams; ++i)
{
AVStream* avstream = _context->streams[i];
+15 -2
View File
@@ -127,6 +127,14 @@ namespace lms::audio::ffmpeg
}
}
{
_estimatedDuration = std::chrono::milliseconds{ _context->duration == AV_NOPTS_VALUE ? 0 : _context->duration / AV_TIME_BASE * 1'000 };
if (_estimatedDuration > offset)
_estimatedDuration = _estimatedDuration - std::chrono::duration_cast<std::chrono::milliseconds>(offset);
else
_estimatedDuration = {};
}
_decoderContext = AVCodecContextPtr{ ::avcodec_alloc_context3(decoder) };
if (!_decoderContext)
throw Exception{ "Cannot allocate decoder context" };
@@ -225,7 +233,7 @@ namespace lms::audio::ffmpeg
else
{
std::array<uint8_t*, AV_NUM_DATA_POINTERS> outData{};
for (size_t i = 0; i < outputChannelBuffers.size(); ++i)
for (std::size_t i{}; i < outputChannelBuffers.size(); ++i)
outData[i] = reinterpret_cast<uint8_t*>(outputChannelBuffers[i].data());
// Resample decoded audio
@@ -266,6 +274,11 @@ namespace lms::audio::ffmpeg
return _finished;
}
std::chrono::milliseconds PcmDecoder::getEstimatedDuration() const
{
return _estimatedDuration;
}
std::size_t PcmDecoder::computeSampleCountPerChannel(std::span<WritableBuffer> outputChannelBuffers) const
{
if (_parameters.planar)
@@ -343,7 +356,7 @@ namespace lms::audio::ffmpeg
std::size_t PcmDecoder::drainResampler(std::span<WritableBuffer> outputChannelBuffers, std::size_t maxSamplesPerChannel)
{
std::array<uint8_t*, AV_NUM_DATA_POINTERS> outData{};
for (size_t i = 0; i < outputChannelBuffers.size(); ++i)
for (std::size_t i{}; i < outputChannelBuffers.size(); ++i)
outData[i] = reinterpret_cast<uint8_t*>(outputChannelBuffers[i].data());
const int outSampleCount{ ::swr_convert(_resampleContext.get(),
@@ -40,6 +40,8 @@ namespace lms::audio::ffmpeg
std::size_t readSamples(std::span<WritableBuffer> outputChannelBuffers) override;
bool finished() const override;
std::chrono::milliseconds getEstimatedDuration() const override;
std::size_t computeSampleCountPerChannel(std::span<WritableBuffer> outputChannelBuffers) const;
void feedDecoder();
std::size_t drainResampler(std::span<WritableBuffer> outputChannelBuffers, std::size_t maxSamplesPerChannel);
@@ -52,6 +54,7 @@ namespace lms::audio::ffmpeg
bool _draining{};
AVFormatContextPtr _context;
std::chrono::milliseconds _estimatedDuration{};
int _inputStreamIndex{};
AVCodecContextPtr _decoderContext;
AVFramePtr _decodedFrame;
+1 -1
View File
@@ -62,7 +62,7 @@ namespace lms::audio::ffmpeg::utils
void avLogCallback(void*, int level, const char* fmt, va_list vl)
{
if (!core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
if (!core::Service<core::logging::ILogger>::get() || core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
return;
if (level > AV_LOG_WARNING)
@@ -0,0 +1,168 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "MusicNNEmbeddingExtractor.hpp"
#include <algorithm>
#include <array>
#include <cassert>
#include <cmath>
#include <numeric>
#include "audio/Exception.hpp"
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include "math/StatsAccumulator.hpp"
#include "musicnn/MusicNNModel.hpp"
namespace lms::audio::musicnn
{
namespace
{
constexpr float minMeaningfulPatchRms{ 0.003F };
template<typename FloatType>
FloatType computeRms(std::span<const FloatType> samples)
{
const FloatType sumSq{ std::transform_reduce(samples.begin(), samples.end(), FloatType{}, std::plus<>{}, [](FloatType s) { return s * s; }) };
return std::sqrt(sumSq / static_cast<FloatType>(samples.size()));
}
// We want something like this:
// gap patch(0) gap patch(1) gap patch(maxPatchCount) gap
std::size_t computePatchGap(std::size_t totalFrameCount, std::size_t patchFrameCount, std::size_t maxPatchCount)
{
assert(maxPatchCount > 0);
assert(patchFrameCount > 0);
const std::size_t patchCount{ std::min(maxPatchCount, totalFrameCount / patchFrameCount) };
if (patchCount == 0)
return 0;
return (totalFrameCount - patchCount * patchFrameCount) / (patchCount + 1);
}
} // namespace
// Accumulates one 187-frame MusicNN mel patch
class MusicNNEmbeddingExtractor::PatchAccumulator
{
public:
void addMelRow(std::span<const float, melBandCount> melRow, float frameRms)
{
assert(_frameCount < patchFrameCount);
const std::size_t offset{ _frameCount * melBandCount };
std::copy(melRow.begin(), melRow.end(), _melMatrix.begin() + static_cast<std::ptrdiff_t>(offset));
_rmsAccum += frameRms;
++_frameCount;
}
void reset() noexcept
{
_frameCount = {};
_rmsAccum = {};
}
[[nodiscard]] bool complete() const { return _frameCount == patchFrameCount; }
[[nodiscard]] bool meaningful() const
{
return (_frameCount > 0) && ((_rmsAccum / static_cast<float>(_frameCount)) >= minMeaningfulPatchRms);
}
[[nodiscard]] std::span<const float, patchFrameCount * melBandCount> data() const
{
return _melMatrix;
}
private:
std::size_t _frameCount{};
float _rmsAccum{};
std::array<float, patchFrameCount * melBandCount> _melMatrix;
};
MusicNNEmbeddingExtractor::MusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount)
: _melFilterBank{ features::computeMelFilterBank(fftSize, sampleRate, melBandCount, melFMin, melFMax) }
, _model{ modelPath }
, _maxPatchCount{ maxPatchCount }
{
static_assert(MusicNNEmbeddingExtractor::windowSize == MusicNNEmbeddingExtractor::fftSize);
if (_maxPatchCount <= 0)
throw audio::Exception{ "MusicNN embedding extractor: max patch count must be > 0" };
}
IMusicNNEmbeddingExtractor::ExtractionResult MusicNNEmbeddingExtractor::extract(const std::filesystem::path& audioFile) const
{
auto frameDecoder{ std::make_unique<FrameDecoder>(audioFile,
PcmParameters{ .channelCount = 1,
.sampleRate = static_cast<unsigned>(sampleRate),
.sampleType = PcmSampleType::Float32,
.byteOrder = std::endian::native,
.planar = false },
frameHopSamples) };
std::array<float, melBandCount> logMelRow{};
std::array<math::StatsAccumulator<float>, decltype(_model)::outputSize> embeddingAccumulators;
ExtractionResult result;
const std::size_t estimatedFrameCount{ frameDecoder->getEstimatedFrameCount() };
// Fallback: use a gap of two patch lengths if the frame count is unknown
const std::size_t patchGapFrameCount{ estimatedFrameCount ? computePatchGap(frameDecoder->getEstimatedFrameCount(), patchFrameCount, _maxPatchCount) : (2 * patchFrameCount) };
const auto patchAccumulator{ std::make_unique<PatchAccumulator>() };
while (true)
{
patchAccumulator->reset();
const auto onFrame{ [&](const FrameDecoder::SpectralFrameView& frame) {
// MusicNN log compression: log10(10000 * mel + 1)
for (std::size_t m{}; m < melBandCount; ++m)
{
const float energy{ _melFilterBank.computeEnergy(m, std::span<const float>(frame.powerSpectrum)) };
logMelRow[m] = std::log10(10000.F * energy + 1.F);
}
const float rms{ computeRms(frame.rawSamples.subspan(0, frameHopSamples)) };
patchAccumulator->addMelRow(logMelRow, rms);
} };
if (patchGapFrameCount > 0 && frameDecoder->skipFrames(patchGapFrameCount) == 0)
break;
if (frameDecoder->decodeFrames(patchFrameCount, onFrame) < patchFrameCount)
break;
assert(patchAccumulator->complete());
if (!patchAccumulator->meaningful())
continue;
const auto embedding{ _model.forward(patchAccumulator->data()) };
for (std::size_t d{}; d < embedding.size(); ++d)
embeddingAccumulators[d].add(embedding[d]);
++result.patchCount;
}
if (result.patchCount > 0)
{
for (std::size_t d{}; d < decltype(_model)::outputSize; ++d)
result.embeddings.mean.values[d] = embeddingAccumulators[d].getMean();
}
return result;
}
} // namespace lms::audio::musicnn
@@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include "MusicNNModel.hpp"
#include "features/MelFilterBank.hpp"
#include "utils/PcmSpectralFrameDecoder.hpp"
namespace lms::audio::musicnn
{
class MusicNNEmbeddingExtractor : public IMusicNNEmbeddingExtractor
{
public:
MusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount);
~MusicNNEmbeddingExtractor() override = default;
MusicNNEmbeddingExtractor(const MusicNNEmbeddingExtractor&) = delete;
MusicNNEmbeddingExtractor& operator=(const MusicNNEmbeddingExtractor&) = delete;
private:
[[nodiscard]] ExtractionResult extract(const std::filesystem::path& audioFile) const override;
// MusicNN signal processing constants (from musicnn/configuration.py and musicnn_torch.py)
static constexpr std::size_t sampleRate{ 16'000 };
static constexpr std::size_t windowSize{ 512 }; // 512-sample Hann window (32 ms)
static constexpr std::size_t fftSize{ 512 };
static constexpr std::size_t frameHopSamples{ 256 }; // 16 ms hop (matches FFT_HOP in musicnn)
static constexpr std::size_t melBandCount{ 96 };
static constexpr float melFMin{ 0.F };
static constexpr float melFMax{ 8'000.F };
static constexpr std::size_t patchFrameCount{ MusicNNModel::inputFrames }; // 187 frames = 3 s
class PatchAccumulator;
using FrameDecoder = PcmSpectralFrameDecoder<512, float>;
const features::MelFilterBank _melFilterBank;
const MusicNNModel _model;
const std::size_t _maxPatchCount;
};
} // namespace lms::audio::musicnn
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "audio/MusicNNEmbeddings.hpp"
#include <bit>
#include <cstring>
#include <limits>
#include "audio/Exception.hpp"
namespace lms::audio
{
static_assert(sizeof(float) == 4);
static_assert(std::numeric_limits<float>::is_iec559);
namespace
{
constexpr uint32_t byteswap32(uint32_t x)
{
return (x >> 24) | ((x >> 8) & 0x0000FF00u) | ((x << 8) & 0x00FF0000u) | (x << 24);
}
void writeFloats(std::span<const float> data, std::span<std::byte> blob)
{
if (blob.size() < data.size() * sizeof(uint32_t))
throw Exception{ "Buffer too small to write MusicNN embeddings" };
for (std::size_t i{}; i < data.size(); ++i)
{
uint32_t bits{ std::bit_cast<uint32_t>(data[i]) };
if constexpr (std::endian::native == std::endian::little)
bits = byteswap32(bits);
std::memcpy(blob.data() + i * 4, &bits, 4);
}
}
void readFloats(std::span<const std::byte> blob, std::span<float> data)
{
if (blob.size() < data.size() * sizeof(uint32_t))
throw Exception{ "Buffer too small to read MusicNN embeddings" };
for (std::size_t i{}; i < data.size(); ++i)
{
uint32_t bits{};
std::memcpy(&bits, blob.data() + i * 4, 4);
if constexpr (std::endian::native == std::endian::little)
bits = byteswap32(bits);
data[i] = std::bit_cast<float>(bits);
}
}
} // anonymous namespace
void trackMusicNNEmbeddingsToBlob(const TrackMusicNNEmbeddings& embeddings, std::span<std::byte> buffer)
{
if (buffer.size() < sizeof(TrackMusicNNEmbeddings))
throw Exception{ "Buffer too small to write TrackMusicNNEmbeddings" };
writeFloats(embeddings.mean.values, buffer.subspan(0, MusicNNEmbedding::size * sizeof(float)));
}
void trackMusicNNEmbeddingsFromBlob(std::span<const std::byte> buffer, TrackMusicNNEmbeddings& embeddings)
{
if (buffer.size() < sizeof(TrackMusicNNEmbeddings))
throw Exception{ "Buffer too small to read TrackMusicNNEmbeddings" };
readFloats(buffer.subspan(0, MusicNNEmbedding::size * sizeof(float)), embeddings.mean.values);
}
} // namespace lms::audio
@@ -0,0 +1,111 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "MusicNNModel.hpp"
#include <string>
#include <onnxruntime_cxx_api.h>
#include "audio/Exception.hpp"
namespace lms::audio::musicnn
{
namespace
{
// Shape of the ONNX model's single input tensor: [batch=1, T=187, mel=96]
const std::array<std::int64_t, 3> inputShape{ 1,
static_cast<std::int64_t>(MusicNNModel::inputFrames),
static_cast<std::int64_t>(MusicNNModel::inputBands) };
// Shape of the ONNX model's single output tensor: [batch=1, embedding=200]
const std::array<std::int64_t, 2> outputShape{ 1,
static_cast<std::int64_t>(MusicNNModel::outputSize) };
constexpr const char* inputName{ "mel_patch" };
constexpr const char* outputName{ "embedding" };
} // namespace
struct MusicNNModel::Impl
{
Ort::Env env;
Ort::SessionOptions sessionOptions;
Ort::Session session;
Ort::MemoryInfo memoryInfo;
explicit Impl(const std::filesystem::path& onnxPath)
: env{ ORT_LOGGING_LEVEL_ERROR, "MusicNN" }
, session{ [&]() -> Ort::Session {
sessionOptions.SetIntraOpNumThreads(1);
sessionOptions.SetInterOpNumThreads(1);
return Ort::Session{ env, onnxPath.c_str(), sessionOptions };
}() }
, memoryInfo{ Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault) }
{
}
};
MusicNNModel::MusicNNModel(const std::filesystem::path& onnxPath)
{
try
{
_impl = std::make_unique<Impl>(onnxPath);
}
catch (const Ort::Exception& e)
{
throw audio::Exception{ std::string{ "Failed to load ONNX model '" } + onnxPath.string() + "': " + e.what() };
}
}
MusicNNModel::~MusicNNModel() = default;
std::array<float, MusicNNModel::outputSize> MusicNNModel::forward(
std::span<const float, inputFrames * inputBands> melPatch) const
{
Ort::Value inputTensor{ Ort::Value::CreateTensor<float>(
_impl->memoryInfo,
const_cast<float*>(melPatch.data()), // safe cast
melPatch.size(),
inputShape.data(),
inputShape.size()) };
std::array<float, outputSize> result{};
Ort::Value outputTensor{ Ort::Value::CreateTensor<float>(
_impl->memoryInfo,
result.data(),
result.size(),
outputShape.data(),
outputShape.size()) };
try
{
auto inputNames{ std::to_array({ inputName }) };
auto outputNames{ std::to_array({ outputName }) };
_impl->session.Run(Ort::RunOptions{ nullptr },
inputNames.data(), &inputTensor, 1,
outputNames.data(), &outputTensor, 1);
}
catch (const Ort::Exception& e)
{
throw audio::Exception{ std::string{ "ONNX inference failed: " } + e.what() };
}
return result;
}
} // namespace lms::audio::musicnn
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <array>
#include <cstddef>
#include <filesystem>
#include <memory>
#include <span>
namespace lms::audio::musicnn
{
// The ONNX model must be exported with:
// input "mel_patch" shape [1, 1, 187, 96]
// output "embedding" shape [1, 200]
//
// Export script: tools/musicnn/export_onnx.py
class MusicNNModel
{
public:
explicit MusicNNModel(const std::filesystem::path& onnxPath);
~MusicNNModel();
MusicNNModel(const MusicNNModel&) = delete;
MusicNNModel& operator=(const MusicNNModel&) = delete;
static inline constexpr std::size_t inputFrames{ 187 };
static inline constexpr std::size_t inputBands{ 96 };
static inline constexpr std::size_t outputSize{ 200 };
[[nodiscard]] std::array<float, outputSize> forward(std::span<const float, inputFrames * inputBands> melPatch) const;
private:
// Pimpl: keep ORT headers out of translation units that include this header.
struct Impl;
std::unique_ptr<Impl> _impl;
};
} // namespace lms::audio::musicnn
@@ -20,6 +20,7 @@
#pragma once
#include <cstddef>
#include <span>
#include <vector>
#include <boost/asio/io_context.hpp>
@@ -0,0 +1,222 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <algorithm>
#include <array>
#include <bit>
#include <cassert>
#include <complex>
#include <concepts>
#include <cstddef>
#include <filesystem>
#include <functional>
#include <memory>
#include <vector>
#include "core/AlignedHeapArray.hpp"
#include "audio/IPcmDecoder.hpp"
#include "audio/PcmTypes.hpp"
#include "math/FFT.hpp"
#include "math/Window.hpp"
namespace lms::audio
{
// Stateful PCM frame decoder that applies a Hann window + FFT per frame.
template<std::size_t WindowSize, typename FloatType = float>
class PcmSpectralFrameDecoder
{
static_assert(std::has_single_bit(WindowSize), "WindowSize must be a power of two");
public:
using FFTPlan = math::FixedRealFFTPlan<WindowSize, FloatType>;
static constexpr std::size_t spectrumSize{ FFTPlan::getOutputSize() };
PcmSpectralFrameDecoder(const std::filesystem::path& audioFile, const PcmParameters& params, std::size_t hopSize)
: PcmSpectralFrameDecoder{ createPcmDecoder(audioFile, {}, params), hopSize }
{
}
~PcmSpectralFrameDecoder() = default;
explicit PcmSpectralFrameDecoder(std::unique_ptr<IPcmDecoder> decoder, std::size_t hopSize)
: _pcmParams{ decoder->getParameters() }
, _hopSize{ hopSize }
, _powerScale{ FloatType{ 1 } / (_window.energy() * static_cast<FloatType>(WindowSize)) }
, _decoder{ std::move(decoder) }
, _samplesBuffer(bufferFrameCount * _hopSize + WindowSize)
, _bufferedSampleCount{ WindowSize / 2 } // first analysis frame centered on sample 0, matching librosa center=True semantics.
{
assert(_hopSize > 0);
}
PcmSpectralFrameDecoder(const PcmSpectralFrameDecoder&) = delete;
PcmSpectralFrameDecoder& operator=(const PcmSpectralFrameDecoder&) = delete;
std::size_t hopSize() const noexcept { return _hopSize; }
const PcmParameters& pcmParameters() const noexcept { return _pcmParams; }
std::size_t getEstimatedFrameCount() const
{
const auto duration{ _decoder->getEstimatedDuration() };
if (duration <= std::chrono::milliseconds::zero())
return 0;
const auto totalSamples{ static_cast<std::size_t>((static_cast<std::uint64_t>(duration.count()) * _pcmParams.sampleRate) / 1'000) };
constexpr std::size_t halfWindow{ WindowSize / 2 };
if (totalSamples < halfWindow) // Not enough samples to produce even the first frame.
return 0;
return 1 + ((totalSamples - halfWindow) / _hopSize);
}
// Spectral data for a single frame.
struct SpectralFrameView
{
std::span<const FloatType, WindowSize> rawSamples;
std::span<const FloatType, spectrumSize> powerSpectrum;
};
// Decodes up to frameCount frames, invoking callback for each. May return fewer than
// frameCount at EOF. Returns 0 only if no frame at all could be decoded.
template<typename Callback>
requires std::invocable<Callback, const SpectralFrameView&>
std::size_t decodeFrames(std::size_t frameCount, Callback&& callback)
{
if (frameCount == 0)
return 0;
std::size_t decodedCount{};
while (decodedCount < frameCount)
{
if (!readAtLeastSamples(std::max(WindowSize, _hopSize)))
break;
const std::span<const FloatType, WindowSize> rawSamples{ _samplesBuffer.data(), WindowSize };
const std::span<FloatType, WindowSize> windowedFrame{ _windowedFrame.data(), WindowSize };
_window.apply(rawSamples, windowedFrame);
_fftPlan.apply(_windowedFrame, _fftOutput);
// Reuse _windowedFrame for power spectrum (spectrumSize <= WindowSize).
const std::span<FloatType, spectrumSize> powerBuffer{ _windowedFrame.data(), spectrumSize };
std::transform(_fftOutput.cbegin(), _fftOutput.cend(), powerBuffer.begin(),
[this](const std::complex<FloatType>& bin) {
return (bin.real() * bin.real() + bin.imag() * bin.imag()) * _powerScale;
});
const SpectralFrameView frame{ .rawSamples = rawSamples,
.powerSpectrum = std::span<const FloatType, spectrumSize>{ _windowedFrame.data(), spectrumSize } };
std::invoke(callback, frame);
consumeSamples(_hopSize);
++_currentFrameIndex;
++decodedCount;
}
return decodedCount;
}
// Skips exactly the next frameCount frames without computing FFT or invoking callbacks.
// Returns frameCount on success, 0 on EOF.
std::size_t skipFrames(std::size_t frameCount)
{
if (frameCount == 0)
return 0;
std::size_t skippedFrameCount{};
while (skippedFrameCount < frameCount)
{
if (!readAtLeastSamples(std::max(WindowSize, _hopSize)))
break;
consumeSamples(_hopSize);
++_currentFrameIndex;
++skippedFrameCount;
}
return skippedFrameCount;
}
[[nodiscard]] std::size_t currentFrameIndex() const noexcept { return _currentFrameIndex; }
private:
static constexpr std::size_t bufferFrameCount{ 20 };
bool readAtLeastSamples(std::size_t sampleCount)
{
if (sampleCount <= _bufferedSampleCount)
return true;
if (_samplesBuffer.size() < sampleCount)
_samplesBuffer.resize(sampleCount);
while ((_bufferedSampleCount < sampleCount) && !_endOfStream)
{
std::span<FloatType> dest{ _samplesBuffer.data() + _bufferedSampleCount, _samplesBuffer.size() - _bufferedSampleCount };
assert(!dest.empty());
if (dest.empty())
break;
std::array outputBuffers{ IPcmDecoder::WritableBuffer{ std::as_writable_bytes(dest) } };
const std::size_t samplesRead{ _decoder->readSamples(outputBuffers) };
if (samplesRead == 0)
{
_endOfStream = true;
break;
}
_bufferedSampleCount += samplesRead;
}
return _bufferedSampleCount >= sampleCount;
}
void consumeSamples(std::size_t samplesToDrop)
{
// TODO use a circular buffer and only compacts at the end of the buffer
assert(samplesToDrop <= _bufferedSampleCount);
const auto remaining{ _bufferedSampleCount - samplesToDrop };
if (remaining)
{
std::move(_samplesBuffer.begin() + samplesToDrop,
_samplesBuffer.begin() + _bufferedSampleCount,
_samplesBuffer.begin());
}
_bufferedSampleCount = remaining;
}
const PcmParameters _pcmParams;
const std::size_t _hopSize;
const math::HannWindow<WindowSize, FloatType> _window;
const FloatType _powerScale;
const FFTPlan _fftPlan{};
std::unique_ptr<IPcmDecoder> _decoder;
std::vector<FloatType> _samplesBuffer;
core::AlignedHeapArray<FloatType, FFTPlan::minBufferAlignment> _windowedFrame{ FFTPlan::getInputSize() };
core::AlignedHeapArray<std::complex<FloatType>, FFTPlan::minBufferAlignment> _fftOutput{ FFTPlan::getOutputSize() };
std::size_t _bufferedSampleCount{};
std::size_t _currentFrameIndex{};
bool _endOfStream{};
};
} // namespace lms::audio