diff --git a/.github/workflows/cpp-audio-dsp.yml b/.github/workflows/cpp-audio-dsp.yml new file mode 100644 index 00000000..8239224c --- /dev/null +++ b/.github/workflows/cpp-audio-dsp.yml @@ -0,0 +1,32 @@ +name: C++ audio DSP + +on: + pull_request: + paths: + - 'native/linthra_audio/**' + - '.github/workflows/cpp-audio-dsp.yml' + push: + branches: [main] + paths: + - 'native/linthra_audio/**' + - '.github/workflows/cpp-audio-dsp.yml' + +permissions: + contents: read + +jobs: + audio-dsp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: >- + cmake -S native/linthra_audio -B build/linthra_audio + -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build/linthra_audio --parallel + + - name: Unit and realtime tests + run: ctest --test-dir build/linthra_audio --output-on-failure diff --git a/native/linthra_audio/CMakeLists.txt b/native/linthra_audio/CMakeLists.txt new file mode 100644 index 00000000..c029c354 --- /dev/null +++ b/native/linthra_audio/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.16) +project(linthra_audio LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(linthra_audio STATIC + src/dsp.cpp + src/c_api.cpp +) + +target_include_directories(linthra_audio + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_compile_options(linthra_audio PRIVATE + $<$:-Wall -Wextra -Wpedantic -Werror> +) + +add_executable(linthra_audio_tests tests/dsp_test.cpp) +target_link_libraries(linthra_audio_tests PRIVATE linthra_audio) + +add_executable(linthra_audio_benchmark tests/realtime_benchmark.cpp) +target_link_libraries(linthra_audio_benchmark PRIVATE linthra_audio) + +enable_testing() +add_test(NAME linthra_audio_tests COMMAND linthra_audio_tests) +add_test(NAME linthra_audio_realtime_budget COMMAND linthra_audio_benchmark) diff --git a/native/linthra_audio/README.md b/native/linthra_audio/README.md new file mode 100644 index 00000000..5c0f29b8 --- /dev/null +++ b/native/linthra_audio/README.md @@ -0,0 +1,32 @@ +# linthra_audio (C++) + +`linthra_audio` is Linthra's realtime DSP core. It gives C++/audio contributors a real part of the project to own while keeping the existing player stable until the mobile binding is ready. + +## Current DSP + +- fixed-size, allocation-free processing state +- mono/stereo float PCM +- preamp +- up to 8 parametric peaking-EQ bands +- stereo-linked peak limiter with immediate attack and smooth release +- transparent bypass when processing is disabled +- C ABI for a future Android/JNI or Dart FFI boundary +- 48 kHz stereo realtime regression benchmark + +The processing callback does not allocate memory and does not take locks. Configuration computes coefficients outside the callback. + +## Build and test + +```bash +cmake -S native/linthra_audio -B build/linthra_audio -DCMAKE_BUILD_TYPE=Release +cmake --build build/linthra_audio --parallel +ctest --test-dir build/linthra_audio --output-on-failure +``` + +## Important boundary + +This PR does not replace `just_audio` or silently alter playback. Linthra currently uses `just_audio`/the platform decoder pipeline, so inserting native PCM DSP safely requires a dedicated Android audio-processor binding. The C++ core is intentionally validated first; the binding can then be reviewed for audio focus, buffering, F-Droid reproducibility, and bypass correctness without also reviewing the DSP math. + +## Good contribution areas + +C++ contributors can work on response tests, limiter behaviour, SIMD implementations, filter types, channel-layout support, loudness/peak analysis, and the future Android audio-processor bridge without needing Flutter UI knowledge. diff --git a/native/linthra_audio/include/linthra_audio/dsp.hpp b/native/linthra_audio/include/linthra_audio/dsp.hpp new file mode 100644 index 00000000..7c992316 --- /dev/null +++ b/native/linthra_audio/include/linthra_audio/dsp.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +namespace linthra::audio { + +constexpr std::size_t kMaxEqBands = 8; + +struct PeakingEqBand { + bool enabled = false; + float frequency_hz = 1000.0F; + float gain_db = 0.0F; + float q = 1.0F; +}; + +struct DspConfig { + float preamp_db = 0.0F; + std::array bands{}; + std::size_t band_count = 0; + bool limiter_enabled = true; + float limiter_threshold_db = -0.3F; + float limiter_release_ms = 80.0F; +}; + +/// Allocation-free, lock-free processing chain for mono/stereo floating-point +/// PCM. Configuration work may calculate coefficients; process() only performs +/// bounded arithmetic over already-prepared state. +class DspChain { + public: + explicit DspChain(float sample_rate) noexcept; + + void configure(const DspConfig& config) noexcept; + void reset() noexcept; + + /// Processes interleaved mono/stereo samples in place. Unsupported channel + /// counts are bypassed rather than risking a bad audio callback. + void process(float* interleaved, std::size_t frames, std::uint32_t channels) noexcept; + + [[nodiscard]] float sample_rate() const noexcept { return sample_rate_; } + [[nodiscard]] DspConfig config() const noexcept { return config_; } + + private: + struct Biquad { + float b0 = 1.0F; + float b1 = 0.0F; + float b2 = 0.0F; + float a1 = 0.0F; + float a2 = 0.0F; + std::array z1{}; + std::array z2{}; + bool enabled = false; + + void configure_peaking(float sample_rate, const PeakingEqBand& band) noexcept; + void reset() noexcept; + float process(float sample, std::size_t channel) noexcept; + }; + + float sample_rate_; + DspConfig config_{}; + std::array biquads_{}; + float preamp_linear_ = 1.0F; + float limiter_threshold_linear_ = 1.0F; + float limiter_gain_ = 1.0F; + float limiter_release_step_ = 1.0F; +}; + +} // namespace linthra::audio diff --git a/native/linthra_audio/include/linthra_audio/linthra_audio.h b/native/linthra_audio/include/linthra_audio/linthra_audio.h new file mode 100644 index 00000000..67a661e3 --- /dev/null +++ b/native/linthra_audio/include/linthra_audio/linthra_audio.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LINTHRA_AUDIO_MAX_EQ_BANDS 8 + +typedef struct LinthraAudioEqBand { + int32_t enabled; + float frequency_hz; + float gain_db; + float q; +} LinthraAudioEqBand; + +typedef struct LinthraAudioConfig { + float preamp_db; + LinthraAudioEqBand bands[LINTHRA_AUDIO_MAX_EQ_BANDS]; + size_t band_count; + int32_t limiter_enabled; + float limiter_threshold_db; + float limiter_release_ms; +} LinthraAudioConfig; + +typedef struct LinthraAudioDsp LinthraAudioDsp; + +/// Creates one DSP instance for a fixed output sample rate. +LinthraAudioDsp* linthra_audio_create(float sample_rate); + +void linthra_audio_destroy(LinthraAudioDsp* dsp); + +/// Replaces all DSP parameters. Safe to call between audio callbacks; the +/// eventual mobile binding is responsible for serializing configuration against +/// process calls so this core never needs a lock in the realtime path. +void linthra_audio_configure(LinthraAudioDsp* dsp, const LinthraAudioConfig* config); + +void linthra_audio_reset(LinthraAudioDsp* dsp); + +/// Processes interleaved float PCM in place. The current core supports mono or +/// stereo and bypasses unsupported channel layouts. +void linthra_audio_process( + LinthraAudioDsp* dsp, + float* interleaved, + size_t frames, + uint32_t channels); + +#ifdef __cplusplus +} +#endif diff --git a/native/linthra_audio/src/c_api.cpp b/native/linthra_audio/src/c_api.cpp new file mode 100644 index 00000000..e29fb1dc --- /dev/null +++ b/native/linthra_audio/src/c_api.cpp @@ -0,0 +1,66 @@ +#include "linthra_audio/linthra_audio.h" + +#include +#include + +#include "linthra_audio/dsp.hpp" + +struct LinthraAudioDsp { + explicit LinthraAudioDsp(float sample_rate) : chain(sample_rate) {} + linthra::audio::DspChain chain; +}; + +extern "C" LinthraAudioDsp* linthra_audio_create(float sample_rate) { + if (!(sample_rate > 0.0F)) { + return nullptr; + } + return new (std::nothrow) LinthraAudioDsp(sample_rate); +} + +extern "C" void linthra_audio_destroy(LinthraAudioDsp* dsp) { + delete dsp; +} + +extern "C" void linthra_audio_configure( + LinthraAudioDsp* dsp, + const LinthraAudioConfig* config) { + if (dsp == nullptr || config == nullptr) { + return; + } + + linthra::audio::DspConfig native{}; + native.preamp_db = config->preamp_db; + native.band_count = std::min( + config->band_count, + static_cast(LINTHRA_AUDIO_MAX_EQ_BANDS)); + native.limiter_enabled = config->limiter_enabled != 0; + native.limiter_threshold_db = config->limiter_threshold_db; + native.limiter_release_ms = config->limiter_release_ms; + + for (size_t index = 0; index < native.band_count; ++index) { + native.bands[index] = linthra::audio::PeakingEqBand{ + config->bands[index].enabled != 0, + config->bands[index].frequency_hz, + config->bands[index].gain_db, + config->bands[index].q, + }; + } + + dsp->chain.configure(native); +} + +extern "C" void linthra_audio_reset(LinthraAudioDsp* dsp) { + if (dsp != nullptr) { + dsp->chain.reset(); + } +} + +extern "C" void linthra_audio_process( + LinthraAudioDsp* dsp, + float* interleaved, + size_t frames, + uint32_t channels) { + if (dsp != nullptr) { + dsp->chain.process(interleaved, frames, channels); + } +} diff --git a/native/linthra_audio/src/dsp.cpp b/native/linthra_audio/src/dsp.cpp new file mode 100644 index 00000000..c86f7088 --- /dev/null +++ b/native/linthra_audio/src/dsp.cpp @@ -0,0 +1,149 @@ +#include "linthra_audio/dsp.hpp" + +#include +#include + +namespace linthra::audio { +namespace { +constexpr float kPi = 3.14159265358979323846F; + +float db_to_linear(float db) noexcept { + return std::pow(10.0F, db / 20.0F); +} +} // namespace + +DspChain::DspChain(float sample_rate) noexcept + : sample_rate_(std::max(sample_rate, 1.0F)) { + configure(DspConfig{}); +} + +void DspChain::configure(const DspConfig& config) noexcept { + config_ = config; + config_.preamp_db = std::clamp(config_.preamp_db, -24.0F, 12.0F); + config_.band_count = std::min(config_.band_count, kMaxEqBands); + config_.limiter_threshold_db = + std::clamp(config_.limiter_threshold_db, -24.0F, 0.0F); + config_.limiter_release_ms = + std::clamp(config_.limiter_release_ms, 5.0F, 2000.0F); + + preamp_linear_ = db_to_linear(config_.preamp_db); + limiter_threshold_linear_ = db_to_linear(config_.limiter_threshold_db); + + const float release_seconds = config_.limiter_release_ms / 1000.0F; + limiter_release_step_ = + 1.0F - std::exp(-1.0F / (release_seconds * sample_rate_)); + + for (std::size_t index = 0; index < kMaxEqBands; ++index) { + if (index < config_.band_count && config_.bands[index].enabled) { + biquads_[index].configure_peaking(sample_rate_, config_.bands[index]); + } else { + biquads_[index] = Biquad{}; + } + } + + limiter_gain_ = 1.0F; +} + +void DspChain::reset() noexcept { + for (auto& biquad : biquads_) { + biquad.reset(); + } + limiter_gain_ = 1.0F; +} + +void DspChain::process( + float* interleaved, + std::size_t frames, + std::uint32_t channels) noexcept { + if (interleaved == nullptr || frames == 0 || channels == 0 || channels > 2) { + return; + } + + for (std::size_t frame = 0; frame < frames; ++frame) { + std::array processed{}; + float frame_peak = 0.0F; + + for (std::size_t channel = 0; channel < channels; ++channel) { + const std::size_t sample_index = frame * channels + channel; + float sample = interleaved[sample_index] * preamp_linear_; + + for (std::size_t band = 0; band < config_.band_count; ++band) { + if (biquads_[band].enabled) { + sample = biquads_[band].process(sample, channel); + } + } + + if (!std::isfinite(sample)) { + sample = 0.0F; + } + processed[channel] = sample; + frame_peak = std::max(frame_peak, std::abs(sample)); + } + + float frame_gain = 1.0F; + if (config_.limiter_enabled) { + const float desired_gain = frame_peak > limiter_threshold_linear_ + ? limiter_threshold_linear_ / std::max(frame_peak, 1.0e-12F) + : 1.0F; + + if (desired_gain < limiter_gain_) { + // Peak protection attacks immediately; recovery is smooth so + // gain does not chatter on consecutive loud frames. + limiter_gain_ = desired_gain; + } else { + limiter_gain_ += + (1.0F - limiter_gain_) * limiter_release_step_; + } + frame_gain = limiter_gain_; + } else { + limiter_gain_ = 1.0F; + } + + for (std::size_t channel = 0; channel < channels; ++channel) { + interleaved[frame * channels + channel] = processed[channel] * frame_gain; + } + } +} + +void DspChain::Biquad::configure_peaking( + float sample_rate, + const PeakingEqBand& band) noexcept { + const float frequency = + std::clamp(band.frequency_hz, 10.0F, sample_rate * 0.49F); + const float gain_db = std::clamp(band.gain_db, -24.0F, 24.0F); + const float q = std::clamp(band.q, 0.1F, 20.0F); + + const float a = std::pow(10.0F, gain_db / 40.0F); + const float omega = 2.0F * kPi * frequency / sample_rate; + const float cosine = std::cos(omega); + const float alpha = std::sin(omega) / (2.0F * q); + + const float raw_b0 = 1.0F + alpha * a; + const float raw_b1 = -2.0F * cosine; + const float raw_b2 = 1.0F - alpha * a; + const float a0 = 1.0F + alpha / a; + const float raw_a1 = -2.0F * cosine; + const float raw_a2 = 1.0F - alpha / a; + + b0 = raw_b0 / a0; + b1 = raw_b1 / a0; + b2 = raw_b2 / a0; + a1 = raw_a1 / a0; + a2 = raw_a2 / a0; + enabled = std::abs(gain_db) > 0.0001F; + reset(); +} + +void DspChain::Biquad::reset() noexcept { + z1 = {}; + z2 = {}; +} + +float DspChain::Biquad::process(float sample, std::size_t channel) noexcept { + const float output = b0 * sample + z1[channel]; + z1[channel] = b1 * sample - a1 * output + z2[channel]; + z2[channel] = b2 * sample - a2 * output; + return output; +} + +} // namespace linthra::audio diff --git a/native/linthra_audio/tests/dsp_test.cpp b/native/linthra_audio/tests/dsp_test.cpp new file mode 100644 index 00000000..be4883ba --- /dev/null +++ b/native/linthra_audio/tests/dsp_test.cpp @@ -0,0 +1,91 @@ +#include "linthra_audio/dsp.hpp" +#include "linthra_audio/linthra_audio.h" + +#include +#include +#include + +namespace { +bool near(float left, float right, float tolerance = 1.0e-4F) { + return std::abs(left - right) <= tolerance; +} +} // namespace + +int main() { + using linthra::audio::DspChain; + using linthra::audio::DspConfig; + + // Transparent bypass: no EQ, no preamp, limiter disabled. + DspChain bypass(48'000.0F); + DspConfig bypass_config{}; + bypass_config.limiter_enabled = false; + bypass.configure(bypass_config); + std::array clean{0.25F, -0.25F, 0.5F, -0.5F, 0.0F, 0.75F}; + const auto original = clean; + bypass.process(clean.data(), 3, 2); + for (std::size_t index = 0; index < clean.size(); ++index) { + assert(near(clean[index], original[index])); + } + + // Preamp is deterministic and clip-free when the limiter is off. + DspChain preamp(48'000.0F); + DspConfig preamp_config{}; + preamp_config.preamp_db = -6.0F; + preamp_config.limiter_enabled = false; + preamp.configure(preamp_config); + std::array preamp_frame{1.0F, -1.0F}; + preamp.process(preamp_frame.data(), 1, 2); + assert(near(preamp_frame[0], 0.501187F, 1.0e-3F)); + assert(near(preamp_frame[1], -0.501187F, 1.0e-3F)); + + // The stereo-linked limiter applies one gain to both channels. This avoids + // pulling a loud channel down independently and shifting the stereo image. + DspChain limiter(48'000.0F); + DspConfig limiter_config{}; + limiter_config.limiter_enabled = true; + limiter_config.limiter_threshold_db = -0.3F; + limiter.configure(limiter_config); + std::array hot_frame{2.0F, 1.0F}; + limiter.process(hot_frame.data(), 1, 2); + const float threshold = std::pow(10.0F, -0.3F / 20.0F); + assert(std::abs(hot_frame[0]) <= threshold + 1.0e-4F); + assert(near(hot_frame[0] / hot_frame[1], 2.0F, 1.0e-3F)); + + // A peaking band should alter an impulse response without producing NaN or + // infinity. Exact coefficients are implementation detail; stability is the + // contract the realtime caller depends on. + DspChain equalizer(48'000.0F); + DspConfig eq_config{}; + eq_config.limiter_enabled = false; + eq_config.band_count = 1; + eq_config.bands[0] = {true, 1000.0F, 6.0F, 0.707F}; + equalizer.configure(eq_config); + std::array impulse{}; + impulse[0] = 0.5F; + equalizer.process(impulse.data(), impulse.size(), 1); + bool changed = false; + for (std::size_t index = 0; index < impulse.size(); ++index) { + assert(std::isfinite(impulse[index])); + if (!near(impulse[index], index == 0 ? 0.5F : 0.0F)) { + changed = true; + } + } + assert(changed); + + // Smoke-test the C ABI that a future JNI/FFI layer will call. + LinthraAudioDsp* c_dsp = linthra_audio_create(48'000.0F); + assert(c_dsp != nullptr); + LinthraAudioConfig c_config{}; + c_config.limiter_enabled = 0; + c_config.preamp_db = 0.0F; + c_config.limiter_threshold_db = -0.3F; + c_config.limiter_release_ms = 80.0F; + linthra_audio_configure(c_dsp, &c_config); + std::array c_samples{0.2F, -0.2F}; + linthra_audio_process(c_dsp, c_samples.data(), 1, 2); + assert(near(c_samples[0], 0.2F)); + assert(near(c_samples[1], -0.2F)); + linthra_audio_destroy(c_dsp); + + return 0; +} diff --git a/native/linthra_audio/tests/realtime_benchmark.cpp b/native/linthra_audio/tests/realtime_benchmark.cpp new file mode 100644 index 00000000..9ae2609f --- /dev/null +++ b/native/linthra_audio/tests/realtime_benchmark.cpp @@ -0,0 +1,69 @@ +#include "linthra_audio/dsp.hpp" + +#include +#include +#include +#include +#include +#include + +int main() { + using Clock = std::chrono::steady_clock; + using linthra::audio::DspChain; + using linthra::audio::DspConfig; + + constexpr float sample_rate = 48'000.0F; + constexpr std::size_t seconds = 60; + constexpr std::size_t channels = 2; + constexpr std::size_t frames = static_cast(sample_rate) * seconds; + constexpr std::size_t block_frames = 256; + constexpr float pi = 3.14159265358979323846F; + + std::vector audio(frames * channels); + for (std::size_t frame = 0; frame < frames; ++frame) { + const float time = static_cast(frame) / sample_rate; + const float sample = 0.7F * std::sin(2.0F * pi * 440.0F * time); + audio[frame * channels] = sample; + audio[frame * channels + 1] = sample * 0.92F; + } + + DspConfig config{}; + config.preamp_db = 1.0F; + config.limiter_enabled = true; + config.limiter_threshold_db = -0.3F; + config.band_count = 5; + config.bands[0] = {true, 80.0F, 1.5F, 0.8F}; + config.bands[1] = {true, 250.0F, -1.0F, 1.0F}; + config.bands[2] = {true, 1000.0F, 0.75F, 1.1F}; + config.bands[3] = {true, 4000.0F, 1.25F, 0.9F}; + config.bands[4] = {true, 12'000.0F, -0.5F, 0.7F}; + + DspChain chain(sample_rate); + chain.configure(config); + + const auto started = Clock::now(); + for (std::size_t offset = 0; offset < frames; offset += block_frames) { + const std::size_t count = std::min(block_frames, frames - offset); + chain.process(audio.data() + offset * channels, count, channels); + } + const auto elapsed = Clock::now() - started; + + const auto elapsed_ms = + std::chrono::duration_cast(elapsed).count(); + const double realtime_ratio = + std::chrono::duration(elapsed).count() / static_cast(seconds); + + std::cout << "Processed " << seconds + << " seconds of 48 kHz stereo / 5-band EQ + limiter in " + << elapsed_ms << " ms (" << realtime_ratio << "x realtime)\n"; + + // Shared runners vary widely. 0.25x realtime is deliberately a broad guard: + // a regression must be extremely large before we risk an audio underrun on + // ordinary hardware, while normal optimized builds should sit far below it. + if (realtime_ratio >= 0.25) { + std::cerr << "DSP exceeded realtime safety budget\n"; + return 1; + } + + return 0; +}