feat(libSceNgs2.native): sampler voice low-pass filter

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Adrià Franch 2026-10-06 01:15:02 +02:00
parent 109a3281f8
commit 819c045327
8 changed files with 297 additions and 3 deletions

View File

@ -25,7 +25,7 @@ if(MINGW)
endif()
if(BUILD_TESTING)
foreach(ngs2Test IN ITEMS Render Atrac9)
foreach(ngs2Test IN ITEMS Render Atrac9 Filter)
string(TOLOWER ${ngs2Test} testName)
add_test_executable(ngs2_${testName}_tests ${CMAKE_CURRENT_SOURCE_DIR}/tests/Ngs2${ngs2Test}.cpp)
target_include_directories(ngs2_${testName}_tests PRIVATE ${LIBS_INCLUDE_DIR} ${CMAKE_SOURCE_DIR}/3rdparty/LibAtrac9/C/src)

View File

@ -46,6 +46,7 @@ static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_SETUP = 0x10000000;
static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_ADD_WAVEFORM_BLOCKS = 0x10000001;
static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_EXIT_LOOP = 0x10000004;
static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_PITCH = 0x10000005;
static constexpr std::uint32_t SCE_NGS2_SAMPLER_VOICE_PARAM_FILTER = 0x1000000a;
static constexpr std::uint32_t SCE_NGS2_SUBMIXER_VOICE_PARAM_SETUP = 0x20000000;
static constexpr std::uint32_t SCE_NGS2_MASTERING_VOICE_PARAM_SETUP = 0x30000000;
static constexpr std::uint32_t SCE_NGS2_MASTERING_VOICE_PARAM_OUTPUT = 0x30000005;
@ -254,6 +255,19 @@ struct Ngs2SamplerVoicePitchParam {
float ratio;
};
struct Ngs2SamplerVoiceFilterParam {
Ngs2VoiceParamHeader header;
std::uint32_t index;
std::uint32_t location;
std::uint32_t type;
std::uint64_t channel_mask;
float frequency;
float q;
float level;
std::uint32_t reserved[3];
};
static_assert(sizeof(Ngs2SamplerVoiceFilterParam) == 56);
struct Ngs2SubmixerVoiceSetupParam {
Ngs2VoiceParamHeader header;
std::uint32_t num_io_channels;

View File

@ -47,6 +47,24 @@ struct Ngs2Atrac9 {
struct Ngs2Voice;
struct Ngs2FilterHistory {
double x1 = 0.0;
double x2 = 0.0;
double y1 = 0.0;
double y2 = 0.0;
};
struct Ngs2Filter {
bool enabled = false;
std::uint64_t bypassMask = 0;
double b0 = 1.0;
double b1 = 0.0;
double b2 = 0.0;
double a1 = 0.0;
double a2 = 0.0;
std::vector<Ngs2FilterHistory> history;
};
struct Ngs2Port {
Ngs2Voice* dest = nullptr;
float volume = 1.0f;
@ -73,6 +91,7 @@ struct Ngs2Voice {
std::uint32_t callbackFlags = 0;
std::vector<Ngs2Port> ports;
std::vector<std::vector<float>> matrices;
std::vector<Ngs2Filter> filters;
std::uint32_t outputId = 0;
std::vector<float> samples;
bool rendering = false;
@ -88,6 +107,7 @@ struct Ngs2Rack {
Ngs2System* system = nullptr;
std::uint32_t rackId = 0;
std::uint32_t maxChannels = 0;
std::uint32_t maxFilters = 0;
Ngs2ContextBufferInfo bufferInfo{};
Ngs2BufferAllocator allocator{};
std::vector<Ngs2Voice> voices;

View File

@ -93,6 +93,7 @@ static int CreateRack(Ngs2Handle systemHandle, std::uint32_t rackId, const RackO
rack->system = system;
rack->rackId = rackId;
rack->maxChannels = RackMaxChannels(rackId, options);
rack->maxFilters = rackId == SCE_NGS2_RACK_ID_SAMPLER ? options.sampler.max_filters : 0;
rack->bufferInfo = bufferInfo;
rack->allocator = allocator;
rack->voices.resize(options.common.max_voices);

View File

@ -99,7 +99,46 @@ static void ConsumeSamples(Ngs2Voice& voice, std::uint32_t grain, std::uint32_t
voice.phase &= PHASE_FRACTION;
}
voice.hasSamples = written != 0;
if (voice.state == Ngs2PlayState::Playing && voice.blocks.empty() && !voice.acceptsBlocks) voice.state = Ngs2PlayState::Empty;
}
static bool Bypasses(const Ngs2Filter& filter, std::uint32_t channel) {
return channel < 64 && (filter.bypassMask & (std::uint64_t{1} << channel)) != 0;
}
static bool HasHistory(const Ngs2Filter& filter) {
if (!filter.enabled) return false;
for (std::uint32_t channel = 0; channel < filter.history.size(); channel++) {
const auto& h = filter.history[channel];
if (!Bypasses(filter, channel) && (h.x1 != 0.0 || h.x2 != 0.0 || h.y1 != 0.0 || h.y2 != 0.0)) return true;
}
return false;
}
static void ApplyFilter(Ngs2Voice& voice, Ngs2Filter& filter, std::uint32_t grain) {
for (std::uint32_t channel = 0; channel < voice.channels; channel++) {
if (Bypasses(filter, channel)) continue;
auto& h = filter.history[channel];
for (std::uint32_t i = 0; i < grain; i++) {
auto& sample = voice.samples[channel * grain + i];
const double x = sample;
double y = filter.b0 * x + filter.b1 * h.x1 + filter.b2 * h.x2 - filter.a1 * h.y1 - filter.a2 * h.y2;
if (std::abs(y) < 1e-20) y = 0.0;
h = {x, h.x1, y, h.y1};
sample = static_cast<float>(y);
}
}
}
static void RenderSampler(Ngs2Voice& voice, std::uint32_t grain, std::uint32_t systemRate) {
ConsumeSamples(voice, grain, systemRate);
for (auto& filter : voice.filters) {
if (!filter.enabled || (!voice.hasSamples && !HasHistory(filter))) continue;
ApplyFilter(voice, filter, grain);
voice.hasSamples = true;
}
if (voice.state == Ngs2PlayState::Playing && voice.blocks.empty() && !voice.acceptsBlocks && std::none_of(voice.filters.begin(), voice.filters.end(), HasHistory)) {
voice.state = Ngs2PlayState::Empty;
}
}
static void RenderVoice(Ngs2Voice& voice, const std::vector<Ngs2Voice*>& voices, std::uint32_t grain, std::uint32_t systemRate);
@ -135,7 +174,7 @@ static void RenderVoice(Ngs2Voice& voice, const std::vector<Ngs2Voice*>& voices,
voice.samples.assign(static_cast<std::size_t>(grain) * voice.channels, 0.0f);
voice.hasSamples = false;
if (voice.state == Ngs2PlayState::Playing && voice.channels != 0) {
if (voice.rack->rackId == SCE_NGS2_RACK_ID_SAMPLER) ConsumeSamples(voice, grain, systemRate);
if (voice.rack->rackId == SCE_NGS2_RACK_ID_SAMPLER) RenderSampler(voice, grain, systemRate);
else MixInputs(voice, voices, grain, systemRate);
}
voice.rendering = false;

View File

@ -3,6 +3,7 @@
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <numbers>
#include <stdexcept>
#include <string>
@ -47,6 +48,7 @@ void Ngs2Voice::ResetSetup() {
SetEvent(SCE_NGS2_VOICE_EVENT_STOP_IMM);
std::fill(ports.begin(), ports.end(), Ngs2Port{});
for (auto& matrix : matrices) matrix.clear();
filters.clear();
channels = 0;
sampleRate = 0;
waveformType = 0;
@ -178,6 +180,40 @@ static void AddWaveformBlocks(Ngs2Voice& voice, const Ngs2SamplerVoiceWaveformBl
}
}
static void SetFilter(Ngs2Voice& voice, const Ngs2SamplerVoiceFilterParam& param) {
if (param.index >= voice.rack->maxFilters) APS5_INVALID_ARG_EX;
if (voice.filters.size() <= param.index) voice.filters.resize(param.index + 1);
auto& filter = voice.filters[param.index];
if (param.type == 0) {
filter = {};
return;
}
if (param.location != 1 || param.type != 1) {
throw std::runtime_error("NGS2: sampler filter location " + std::to_string(param.location) + " type " + std::to_string(param.type) + " is not implemented");
}
if (!std::isfinite(param.frequency) || param.frequency < 0.0f || !std::isfinite(param.q) || param.q <= 0.0f || !std::isfinite(param.level) || param.level < 0.0f) {
APS5_INVALID_ARG_EX;
}
const double rate = voice.rack->system->option.sample_rate;
filter.enabled = true;
filter.bypassMask = param.channel_mask;
filter.history.resize(voice.channels);
if (param.frequency == 0.0f || param.frequency >= rate * 0.5) {
filter.b0 = param.frequency == 0.0f ? 0.0 : param.level;
filter.b1 = filter.b2 = filter.a1 = filter.a2 = 0.0;
return;
}
const double omega = 2.0 * std::numbers::pi * param.frequency / rate;
const double cosine = std::cos(omega);
const double alpha = std::sin(omega) / (2.0 * param.q);
const double inverseA0 = 1.0 / (1.0 + alpha);
filter.b0 = (1.0 - cosine) * 0.5 * inverseA0 * param.level;
filter.b1 = 2.0 * filter.b0;
filter.b2 = filter.b0;
filter.a1 = -2.0 * cosine * inverseA0;
filter.a2 = (1.0 - alpha) * inverseA0;
}
static void ApplySamplerParam(Ngs2Voice& voice, const Ngs2VoiceParamHeader& param) {
switch (param.id) {
case SCE_NGS2_SAMPLER_VOICE_PARAM_SETUP: SetupSampler(voice, ParamAs<Ngs2SamplerVoiceSetupParam>(param).format); break;
@ -193,6 +229,7 @@ static void ApplySamplerParam(Ngs2Voice& voice, const Ngs2VoiceParamHeader& para
voice.pitch = ratio;
break;
}
case SCE_NGS2_SAMPLER_VOICE_PARAM_FILTER: SetFilter(voice, ParamAs<Ngs2SamplerVoiceFilterParam>(param)); break;
default: throw std::runtime_error("NGS2: sampler voice param " + Ngs2Hex(param.id) + " is not implemented");
}
}

View File

@ -0,0 +1,182 @@
#include "Ngs2Test.hpp"
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <numbers>
#include <stdexcept>
#include <vector>
struct Biquad {
double b0, b1, b2, a1, a2;
double x1 = 0.0, x2 = 0.0, y1 = 0.0, y2 = 0.0;
Biquad(double frequency, double q, double level, double rate) {
const double w = 2.0 * std::numbers::pi * frequency / rate;
const double alpha = std::sin(w) / (2.0 * q);
const double a0 = 1.0 + alpha;
b0 = (1.0 - std::cos(w)) / 2.0 / a0 * level;
b1 = (1.0 - std::cos(w)) / a0 * level;
b2 = b0;
a1 = -2.0 * std::cos(w) / a0;
a2 = (1.0 - alpha) / a0;
}
float Next(double x) {
const double y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
x2 = x1;
x1 = x;
y2 = y1;
y1 = y;
return static_cast<float>(y);
}
};
static Ngs2SamplerVoiceFilterParam Filter(std::uint32_t index, std::uint32_t location, std::uint32_t type, std::uint64_t mask, float frequency, float q, float level) {
Ngs2SamplerVoiceFilterParam param{};
param.header = {sizeof(param), 0, SCE_NGS2_SAMPLER_VOICE_PARAM_FILTER};
param.index = index;
param.location = location;
param.type = type;
param.channel_mask = mask;
param.frequency = frequency;
param.q = q;
param.level = level;
return param;
}
static void SetFilter(uintptr_t voice, const Ngs2SamplerVoiceFilterParam& param) {
Require(sceNgs2VoiceControl(voice, &param.header) == SCE_NGS2_OK);
}
static uintptr_t Sampler(uintptr_t system, const std::vector<std::int16_t>& pcm, std::uint32_t channels, std::uint32_t rate) {
const auto voice = Voice(CreateRack(system, SCE_NGS2_RACK_ID_SAMPLER));
Control(voice, SCE_NGS2_SAMPLER_VOICE_PARAM_SETUP, Ngs2SamplerVoiceSetupParam{{}, {SCE_NGS2_WAVEFORM_TYPE_PCM_I16L, channels, rate, 0, 0, 0}});
const auto frames = static_cast<std::uint32_t>(pcm.size() / channels);
const Ngs2WaveformBlock block{0, pcm.size() * sizeof(std::int16_t), 0, 0, frames, 0, 0};
Control(voice, SCE_NGS2_SAMPLER_VOICE_PARAM_ADD_WAVEFORM_BLOCKS, Ngs2SamplerVoiceWaveformBlocksParam{{}, pcm.data(), 0, 1, &block});
return voice;
}
static std::vector<float> RenderGrain(uintptr_t system, std::uint32_t channels) {
std::vector<float> out(Grain * channels, -1.0f);
const Ngs2RenderBufferInfo info{out.data(), out.size() * sizeof(float), SCE_NGS2_WAVEFORM_TYPE_PCM_F32L, channels};
Require(sceNgs2SystemRender(system, &info, 1) == SCE_NGS2_OK);
return out;
}
static void TestLowPassAndTail() {
const auto system = CreateSystem();
const auto master = Mastering(system, 1);
std::vector<std::int16_t> pcm(Grain * 2, 0);
pcm[0] = 16384;
pcm[5] = -8192;
const auto sampler = Sampler(system, pcm, 1, 48000);
Patch(sampler, master);
const auto param = Filter(7, 1, 1, 0, 1000.0f, 0.70710678f, 0.75f);
SetFilter(sampler, param);
Event(sampler, SCE_NGS2_VOICE_EVENT_PLAY);
Biquad reference(1000.0, static_cast<double>(0.70710678f), 0.75, 48000.0);
for (std::uint32_t grain = 0; grain < 4; grain++) {
const auto out = RenderGrain(system, 1);
for (std::uint32_t i = 0; i < Grain; i++) {
const auto frame = grain * Grain + i;
const float expected = reference.Next(frame < pcm.size() ? pcm[frame] / 32768.0 : 0.0);
Require(std::abs(out[i] - expected) <= 1e-7f);
}
Require(out[Grain - 1] != 0.0f);
Require(Flags(sampler) == (SCE_NGS2_VOICE_STATE_FLAG_INUSE | SCE_NGS2_VOICE_STATE_FLAG_PLAYING));
SetFilter(sampler, param);
}
SetFilter(sampler, Filter(7, 0, 0, 0, NAN, 0.0f, -1.0f));
for (float sample : RenderGrain(system, 1)) Require(sample == 0.0f);
Require(Flags(sampler) == 0);
Require(sceNgs2SystemDestroy(system, nullptr) == SCE_NGS2_OK);
}
static void TestBypassMaskAndGain() {
const auto system = CreateSystem();
const auto master = Mastering(system, 2);
std::vector<std::int16_t> pcm;
for (std::uint32_t i = 0; i < Grain; i++) {
pcm.push_back(static_cast<std::int16_t>(i * 1000));
pcm.push_back(static_cast<std::int16_t>(-4000 + i * 700));
}
const auto sampler = Sampler(system, pcm, 2, 48000);
Patch(sampler, master);
SetFilter(sampler, Filter(0, 1, 1, 1, 24000.0f, 1.0f, 0.5f));
Event(sampler, SCE_NGS2_VOICE_EVENT_PLAY);
auto out = RenderGrain(system, 2);
for (std::uint32_t i = 0; i < Grain; i++) {
Require(out[i * 2] == pcm[i * 2] / 32768.0f);
Require(out[i * 2 + 1] == pcm[i * 2 + 1] / 32768.0f * 0.5f);
}
Require(Flags(sampler) == (SCE_NGS2_VOICE_STATE_FLAG_INUSE | SCE_NGS2_VOICE_STATE_FLAG_PLAYING));
out = RenderGrain(system, 2);
for (float sample : out) Require(sample == 0.0f);
Require(Flags(sampler) == 0);
Require(sceNgs2SystemDestroy(system, nullptr) == SCE_NGS2_OK);
}
static void TestSystemRateAndZeroCutoff() {
const auto system = CreateSystem();
const auto master = Mastering(system, 1);
const std::vector<std::int16_t> pcm(Grain * 2, 8192);
const auto sampler = Sampler(system, pcm, 1, 24000);
Patch(sampler, master);
SetFilter(sampler, Filter(0, 1, 1, 0, 18000.0f, 0.70710678f, 1.0f));
SetFilter(sampler, Filter(1, 1, 1, 0, 30000.0f, 0.70710678f, 1.0f));
Event(sampler, SCE_NGS2_VOICE_EVENT_PLAY);
Biquad reference(18000.0, static_cast<double>(0.70710678f), 1.0, 48000.0);
const auto out = RenderGrain(system, 1);
for (std::uint32_t i = 0; i < Grain; i++) Require(std::abs(out[i] - reference.Next(0.25)) <= 1e-7f);
SetFilter(sampler, Filter(1, 1, 1, 0, 0.0f, 0.70710678f, 1.0f));
for (float sample : RenderGrain(system, 1)) Require(sample == 0.0f);
Control(sampler, SCE_NGS2_SAMPLER_VOICE_PARAM_SETUP, Ngs2SamplerVoiceSetupParam{{}, {SCE_NGS2_WAVEFORM_TYPE_PCM_I16L, 1, 48000, 0, 0, 0}});
const Ngs2WaveformBlock block{0, pcm.size() * sizeof(std::int16_t), 0, 0, static_cast<std::uint32_t>(pcm.size()), 0, 0};
Control(sampler, SCE_NGS2_SAMPLER_VOICE_PARAM_ADD_WAVEFORM_BLOCKS, Ngs2SamplerVoiceWaveformBlocksParam{{}, pcm.data(), 0, 1, &block});
Patch(sampler, master);
Event(sampler, SCE_NGS2_VOICE_EVENT_PLAY);
for (float sample : RenderGrain(system, 1)) Require(sample == 0.25f);
Require(sceNgs2SystemDestroy(system, nullptr) == SCE_NGS2_OK);
}
template <typename TError>
static void RequireRejected(uintptr_t voice, const Ngs2SamplerVoiceFilterParam& param) {
bool rejected = false;
try {
sceNgs2VoiceControl(voice, &param.header);
} catch (const TError&) {
rejected = true;
}
Require(rejected);
}
static void TestRejectedParams() {
const auto system = CreateSystem();
const auto sampler = Sampler(system, std::vector<std::int16_t>(Grain, 0), 1, 48000);
RequireRejected<std::invalid_argument>(sampler, Filter(8, 1, 1, 0, 1000.0f, 1.0f, 1.0f));
RequireRejected<std::runtime_error>(sampler, Filter(0, 0, 1, 0, 1000.0f, 1.0f, 1.0f));
RequireRejected<std::runtime_error>(sampler, Filter(0, 1, 2, 0, 1000.0f, 1.0f, 1.0f));
RequireRejected<std::invalid_argument>(sampler, Filter(0, 1, 1, 0, NAN, 1.0f, 1.0f));
RequireRejected<std::invalid_argument>(sampler, Filter(0, 1, 1, 0, -1.0f, 1.0f, 1.0f));
RequireRejected<std::invalid_argument>(sampler, Filter(0, 1, 1, 0, 1000.0f, 0.0f, 1.0f));
RequireRejected<std::invalid_argument>(sampler, Filter(0, 1, 1, 0, 1000.0f, 1.0f, -0.5f));
auto small = Filter(0, 1, 1, 0, 1000.0f, 1.0f, 1.0f);
small.header.size = sizeof(small) - 4;
RequireRejected<std::invalid_argument>(sampler, small);
Require(sceNgs2SystemDestroy(system, nullptr) == SCE_NGS2_OK);
}
int main() {
TestLowPassAndTail();
TestBypassMaskAndGain();
TestSystemRateAndZeroCutoff();
TestRejectedParams();
return 0;
}

View File

@ -124,6 +124,7 @@ Throughout the project, every function at every stage either **does exactly what
- [sceShareGetCurrentStatus](../../core/libs/prx/libSceShare/Export.cpp) (libSceShare) - validation (feature flag non-zero, status non-null) and the all-zero 16-byte status taken from the [KytyPS5](https://github.com/KytyPS5/KytyPS5) reimplementation, not confirmed on a PS5 title; the meaning of the recording status values is unknown
- [sceAgcDcbSetZPassPredicationEnableGetSize, sceAgcDcbSetPredicationDisableGetSize, sceAgcDcbSetBoolPredicationEnableGetSize](../../core/libs/prx/libSceAgc/DcbState/src/Predication.cpp) (libSceAgc) - the builders are inline in the SDK and not exported; the size is assumed to be that of the SET_PREDICATION packet `sceAgcDcbSetPredication` writes (16 bytes)
- [Resource registration](../../core/libs/prx/libSceAgcDriver/Resource/src/Registration.cpp) (libSceAgcDriver) - registration always fails with `RESOURCE_REGISTRATION_UNAVAILABLE`, so the owner and resource queries, `sceAgcDriverSetResourceUserData`, `sceAgcDriverFindResourcesPublic` and `sceAgcDriverUnregisterAllResourcesForOwner` return the same error without reading their arguments. Their parameter lists follow the libSceGnmDriver counterparts and are unverified
- [sceNgs2VoiceControl sampler filter](../../core/libs/prx/libSceNgs2.native/src/Voice.cpp) (libSceNgs2.native) - param `0x1000000a`, its layout and meaning follow KytyPS5 only: location 1 type 1 is an RBJ low-pass biquad at the system sample rate after resampling, scaled by `level`, a set `channel_mask` bit bypasses that channel, type 0 turns the filter off. Other locations and types throw. The voice keeps playing a filter tail after its last block until the history is zero, with outputs below 1e-20 flushed to zero (a KytyPS5 choice); the console's tail length is unknown
### Functional