From 819c04532750346efbbffa34f2fd689002f30ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Franch?= Date: Tue, 6 Oct 2026 01:15:02 +0200 Subject: [PATCH] feat(libSceNgs2.native): sampler voice low-pass filter Co-Authored-By: Claude --- .../libs/prx/libSceNgs2.native/CMakeLists.txt | 2 +- .../libSceNgs2.native/include/Ngs2Types.hpp | 14 ++ .../libSceNgs2.native/src/Ngs2Internal.hpp | 20 ++ core/libs/prx/libSceNgs2.native/src/Rack.cpp | 1 + .../libs/prx/libSceNgs2.native/src/Render.cpp | 43 ++++- core/libs/prx/libSceNgs2.native/src/Voice.cpp | 37 ++++ .../libSceNgs2.native/tests/Ngs2Filter.cpp | 182 ++++++++++++++++++ docs/dev/TechnicalDebt.md | 1 + 8 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 core/libs/prx/libSceNgs2.native/tests/Ngs2Filter.cpp diff --git a/core/libs/prx/libSceNgs2.native/CMakeLists.txt b/core/libs/prx/libSceNgs2.native/CMakeLists.txt index 7af3c3299..986014221 100644 --- a/core/libs/prx/libSceNgs2.native/CMakeLists.txt +++ b/core/libs/prx/libSceNgs2.native/CMakeLists.txt @@ -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) diff --git a/core/libs/prx/libSceNgs2.native/include/Ngs2Types.hpp b/core/libs/prx/libSceNgs2.native/include/Ngs2Types.hpp index aa7752af9..6c99f3f42 100644 --- a/core/libs/prx/libSceNgs2.native/include/Ngs2Types.hpp +++ b/core/libs/prx/libSceNgs2.native/include/Ngs2Types.hpp @@ -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; diff --git a/core/libs/prx/libSceNgs2.native/src/Ngs2Internal.hpp b/core/libs/prx/libSceNgs2.native/src/Ngs2Internal.hpp index 164934cb9..615380f59 100644 --- a/core/libs/prx/libSceNgs2.native/src/Ngs2Internal.hpp +++ b/core/libs/prx/libSceNgs2.native/src/Ngs2Internal.hpp @@ -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 history; +}; + struct Ngs2Port { Ngs2Voice* dest = nullptr; float volume = 1.0f; @@ -73,6 +91,7 @@ struct Ngs2Voice { std::uint32_t callbackFlags = 0; std::vector ports; std::vector> matrices; + std::vector filters; std::uint32_t outputId = 0; std::vector 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 voices; diff --git a/core/libs/prx/libSceNgs2.native/src/Rack.cpp b/core/libs/prx/libSceNgs2.native/src/Rack.cpp index 385349f7c..b98f15b96 100644 --- a/core/libs/prx/libSceNgs2.native/src/Rack.cpp +++ b/core/libs/prx/libSceNgs2.native/src/Rack.cpp @@ -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); diff --git a/core/libs/prx/libSceNgs2.native/src/Render.cpp b/core/libs/prx/libSceNgs2.native/src/Render.cpp index 87f29408c..ba5b520b3 100644 --- a/core/libs/prx/libSceNgs2.native/src/Render.cpp +++ b/core/libs/prx/libSceNgs2.native/src/Render.cpp @@ -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(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& voices, std::uint32_t grain, std::uint32_t systemRate); @@ -135,7 +174,7 @@ static void RenderVoice(Ngs2Voice& voice, const std::vector& voices, voice.samples.assign(static_cast(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; diff --git a/core/libs/prx/libSceNgs2.native/src/Voice.cpp b/core/libs/prx/libSceNgs2.native/src/Voice.cpp index 0f2889fed..b92ece2f4 100644 --- a/core/libs/prx/libSceNgs2.native/src/Voice.cpp +++ b/core/libs/prx/libSceNgs2.native/src/Voice.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -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(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(param)); break; default: throw std::runtime_error("NGS2: sampler voice param " + Ngs2Hex(param.id) + " is not implemented"); } } diff --git a/core/libs/prx/libSceNgs2.native/tests/Ngs2Filter.cpp b/core/libs/prx/libSceNgs2.native/tests/Ngs2Filter.cpp new file mode 100644 index 000000000..0c7f193c4 --- /dev/null +++ b/core/libs/prx/libSceNgs2.native/tests/Ngs2Filter.cpp @@ -0,0 +1,182 @@ +#include "Ngs2Test.hpp" + +#include +#include +#include +#include +#include +#include + +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(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, ¶m.header) == SCE_NGS2_OK); +} + +static uintptr_t Sampler(uintptr_t system, const std::vector& 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(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 RenderGrain(uintptr_t system, std::uint32_t channels) { + std::vector 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 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(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 pcm; + for (std::uint32_t i = 0; i < Grain; i++) { + pcm.push_back(static_cast(i * 1000)); + pcm.push_back(static_cast(-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 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(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(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 +static void RequireRejected(uintptr_t voice, const Ngs2SamplerVoiceFilterParam& param) { + bool rejected = false; + try { + sceNgs2VoiceControl(voice, ¶m.header); + } catch (const TError&) { + rejected = true; + } + Require(rejected); +} + +static void TestRejectedParams() { + const auto system = CreateSystem(); + const auto sampler = Sampler(system, std::vector(Grain, 0), 1, 48000); + RequireRejected(sampler, Filter(8, 1, 1, 0, 1000.0f, 1.0f, 1.0f)); + RequireRejected(sampler, Filter(0, 0, 1, 0, 1000.0f, 1.0f, 1.0f)); + RequireRejected(sampler, Filter(0, 1, 2, 0, 1000.0f, 1.0f, 1.0f)); + RequireRejected(sampler, Filter(0, 1, 1, 0, NAN, 1.0f, 1.0f)); + RequireRejected(sampler, Filter(0, 1, 1, 0, -1.0f, 1.0f, 1.0f)); + RequireRejected(sampler, Filter(0, 1, 1, 0, 1000.0f, 0.0f, 1.0f)); + RequireRejected(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(sampler, small); + Require(sceNgs2SystemDestroy(system, nullptr) == SCE_NGS2_OK); +} + +int main() { + TestLowPassAndTail(); + TestBypassMaskAndGain(); + TestSystemRateAndZeroCutoff(); + TestRejectedParams(); + return 0; +} diff --git a/docs/dev/TechnicalDebt.md b/docs/dev/TechnicalDebt.md index cd0f433c1..b6f5d72b9 100644 --- a/docs/dev/TechnicalDebt.md +++ b/docs/dev/TechnicalDebt.md @@ -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