Skip to content
Merged
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
32 changes: 32 additions & 0 deletions .github/workflows/cpp-audio-dsp.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
TheZupZup marked this conversation as resolved.

- name: Build
run: cmake --build build/linthra_audio --parallel

- name: Unit and realtime tests
run: ctest --test-dir build/linthra_audio --output-on-failure
30 changes: 30 additions & 0 deletions native/linthra_audio/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
$<$<CXX_COMPILER_ID:Clang,GNU>:-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)
32 changes: 32 additions & 0 deletions native/linthra_audio/README.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions native/linthra_audio/include/linthra_audio/dsp.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#pragma once

#include <array>
#include <cstddef>
#include <cstdint>

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<PeakingEqBand, kMaxEqBands> 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<float, 2> z1{};
std::array<float, 2> 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<Biquad, kMaxEqBands> 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
52 changes: 52 additions & 0 deletions native/linthra_audio/include/linthra_audio/linthra_audio.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#pragma once

#include <stddef.h>
#include <stdint.h>

#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
66 changes: 66 additions & 0 deletions native/linthra_audio/src/c_api.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#include "linthra_audio/linthra_audio.h"

#include <algorithm>
#include <new>

#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<size_t>(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);
}
}
Loading
Loading