From f7ae6e0a83a9b2abdba67882ba791d585bf0cadf Mon Sep 17 00:00:00 2001 From: Devran Cosmo Uenal Date: Fri, 25 Sep 2026 13:44:58 +0200 Subject: [PATCH] audio: support NGS2 PCM16 samplers and rear low-pass filters (#825) * audio: support standard NGS2 PCM16 sampler playback Extend the existing fixed-point PCM path to standard samplers, loops, and progress reporting. Apply voice events immediately and honor callback interruption during source advancement. Reuse upstream rate and pitch handling. Add regression coverage for streaming, routing, callbacks, loops, and custom sampler controls; ngs2_sampler passes. * audio: apply NGS2 rear low-pass filters Handle rear low-pass FCQ controls at the output sample rate with per-channel history and channel masks. Preserve filter tails through streaming gaps and use the standard pi constant for coefficients. Add regression coverage for frequency response, control layout, channel isolation, and starvation/refill continuity. Emulator build and both audio test suites pass. * Preserve resampling phase for accepting voices Only reset voice.sample_phase when there are no blocks and the voice does not accept blocks (change in src/libs/ngs2.cpp). Add TestResamplingPhaseAfterStarvation to tests to ensure sampler and custom sampler retain resampling phase and pending source advancement after starvation, and that SetupSampler still resets the phase. This prevents resampling phase loss during starvation/refill across sample rates. * audio: correct sampler controls, lifecycle, and filter tails --------- Co-authored-by: nmzik --- CMakeLists.txt | 9 + src/libs/ngs2.cpp | 383 +++++++++++++++------ tests/Ngs2SamplerTests.cpp | 689 +++++++++++++++++++++++++++++++++++++ 3 files changed, 985 insertions(+), 96 deletions(-) create mode 100644 tests/Ngs2SamplerTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 013e2bb59..6122218fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -465,6 +465,13 @@ add_executable(audio_out2_port_tests EXCLUDE_FROM_ALL target_link_libraries(audio_out2_port_tests common fmt::fmt) target_include_directories(audio_out2_port_tests PRIVATE ${inc_headers}) +add_executable(ngs2_sampler_tests EXCLUDE_FROM_ALL + "${KYTY_TESTS_DIR}/Ngs2SamplerTests.cpp" + "${KYTY_SOURCE_DIR}/loader/timer.cpp" +) +target_link_libraries(ngs2_sampler_tests common fmt::fmt LibAtrac9) +target_include_directories(ngs2_sampler_tests PRIVATE ${inc_headers}) + add_executable(save_data_memory_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/SaveDataMemoryTests.cpp" ) @@ -555,6 +562,7 @@ if(BUILD_TESTING) add_test(NAME kernel_file_system COMMAND $) add_test(NAME sync_on_address COMMAND $) add_test(NAME audio_out2_port COMMAND $) + add_test(NAME ngs2_sampler COMMAND $) add_test(NAME save_data_memory COMMAND $) add_test(NAME shader_recompiler_compute COMMAND $) add_test(NAME shader_recompiler_alignbyte @@ -620,6 +628,7 @@ if(BUILD_TESTING) shader_recompiler_compute_tests virtual_memory_allocation_tests audio_out2_port_tests + ngs2_sampler_tests ) endif() diff --git a/src/libs/ngs2.cpp b/src/libs/ngs2.cpp index 235334d89..11846b6a6 100644 --- a/src/libs/ngs2.cpp +++ b/src/libs/ngs2.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include "libatrac9.h" @@ -385,8 +386,114 @@ struct Ngs2RackInternal { std::array fx {}; }; -enum class Ngs2VoicePlayState { Empty, Playing, Paused, Stopped }; -enum class Ngs2VoicePlayEvent { None, Play, Pause, Resume, Stop, StopImm, Kill }; +enum class Ngs2VoicePlayState : uint32_t { Empty = 0, Playing = 3, Paused = 5, Stopped = 0xb }; + +// The 64-bit bypass mask precedes the filter type and FCQ parameters. +struct Ngs2SamplerFilterParam { + uint32_t index; + uint32_t location; + uint64_t channel_mask; + uint32_t type; + float frequency; + float q; + float level; + uint32_t reserved[3]; +}; +static_assert(sizeof(Ngs2SamplerFilterParam) == 48); + +struct Ngs2SamplerFilter { + struct History { + double x1 = 0, x2 = 0, y1 = 0, y2 = 0; + }; + bool enabled = false; + bool warned = false; + uint64_t channel_mask = 0; + double b0 = 1, b1 = 0, b2 = 0, a1 = 0, a2 = 0; + std::vector history; + + void Configure(const Ngs2SamplerFilterParam& param, uint32_t rate, uint32_t channels) { + if (param.type == 0) { + enabled = false; + history.clear(); + return; + } + if (param.location != 1 || param.type != 1 || !std::isfinite(param.frequency) || + param.frequency < 0 || !std::isfinite(param.q) || param.q <= 0 || + !std::isfinite(param.level) || param.level < 0 || rate == 0) { + enabled = false; + history.clear(); + if (!warned) { + Log::WriteToConsoleAndLog(fmt::sprintf( + "warning: unsupported NGS2 sampler filter (location=%u, type=%u)\n", + param.location, param.type)); + warned = true; + } + return; + } + channel_mask = param.channel_mask; + history.resize(channels); + enabled = true; + if (param.frequency == 0 || param.frequency >= rate * 0.5) { + b0 = param.frequency == 0 ? 0 : param.level; + b1 = b2 = a1 = a2 = 0; + return; + } + // RBJ low-pass biquad, normalized by a0 (Audio EQ Cookbook). + 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 inverse_a0 = 1.0 / (1.0 + alpha); + b0 = (1.0 - cosine) * 0.5 * inverse_a0 * param.level; + b1 = 2.0 * b0; + b2 = b0; + a1 = -2.0 * cosine * inverse_a0; + a2 = (1.0 - alpha) * inverse_a0; + } + + bool Bypasses(size_t channel) const { + return channel < 64 && (channel_mask & (uint64_t {1} << channel)) != 0; + } + + bool HasHistory() const { + if (!enabled) { + return false; + } + for (size_t c = 0; c < history.size(); ++c) { + if (Bypasses(c)) { + continue; + } + const auto& h = history[c]; + if (h.x1 != 0 || h.x2 != 0 || h.y1 != 0 || h.y2 != 0) { + return true; + } + } + return false; + } + + void Process(std::vector& samples, uint32_t channels, uint32_t grain) { + if (!enabled) { + return; + } + for (uint32_t c = 0; c < channels; ++c) { + if (Bypasses(c)) { + continue; + } + auto& h = history[c]; + for (uint32_t i = 0; i < grain; ++i) { + const double x = samples[c * grain + i]; + double y = b0 * x + b1 * h.x1 + b2 * h.x2 - a1 * h.y1 - a2 * h.y2; + if (std::abs(y) < 1e-20) { + y = 0; + } + h.x2 = h.x1; + h.x1 = x; + h.y2 = h.y1; + h.y1 = y; + samples[c * grain + i] = static_cast(y); + } + } + } +}; struct Ngs2VoiceInternal { struct Port { @@ -401,6 +508,7 @@ struct Ngs2VoiceInternal { uint32_t cursor = 0; size_t data_cursor = 0; uint32_t skip_remaining = info.num_skip_samples; + uint32_t repeated_count = 0; }; struct Module { std::vector param, work, state; @@ -408,8 +516,8 @@ struct Ngs2VoiceInternal { bool control_warned = false; bool render_warned = false; }; - Ngs2VoicePlayEvent event = Ngs2VoicePlayEvent::None; Ngs2VoicePlayState state = Ngs2VoicePlayState::Empty; + uint32_t state_flags = 0; Ngs2RackInternal* rack = nullptr; uintptr_t callback = 0; uintptr_t callback_data = 0; @@ -428,21 +536,45 @@ struct Ngs2VoiceInternal { std::unique_ptr decoder; std::vector decoded_frame; - bool accepts_blocks = true; - uint32_t sample_rate = 0; - uint64_t sample_phase = 0; - uint64_t sample_step = 0; + bool accepts_blocks = true; + uint32_t sample_rate = 0; + uint64_t sample_phase = 0; + uint64_t sample_step = 0; + uint64_t decoded_samples = 0; + uint64_t decoded_bytes = 0; + const uint8_t* waveform_end = nullptr; + std::vector filters; + + const uint8_t* WaveformData() const { + if (blocks.empty()) { + return waveform_end; + } + const auto& block = blocks.front(); + return block.data + (decoder != nullptr + ? block.data_cursor + : (uint64_t(block.info.num_skip_samples) + block.cursor) * + channels * sizeof(int16_t)); + } void SetupSampler(const Ngs2WaveformFormat& format) { EXIT_NOT_IMPLEMENTED(format.frame_margin != 0 || format.frame_offset != 0 || - format.sample_rate == 0 || + format.sample_rate == 0 || format.num_channels == 0 || (format.waveform_type == NGS2_WAVEFORM_TYPE_ATRAC9 && format.sample_rate != rack->ngs->option.sample_rate)); - channels = format.num_channels; - sample_rate = format.sample_rate; - sample_phase = 0; - sample_step = uint64_t(sample_rate) << 32u; + SetEvent(4); + std::ranges::fill(ports, Port {}); + for (auto& matrix: matrices) { + matrix.clear(); + } + channels = format.num_channels; + sample_rate = format.sample_rate; + sample_phase = 0; + sample_step = uint64_t(sample_rate) << 32u; + decoded_samples = 0; + decoded_bytes = 0; + waveform_end = nullptr; blocks.clear(); + filters.clear(); decoder.reset(); decoded_frame.clear(); accepts_blocks = true; @@ -481,12 +613,30 @@ struct Ngs2VoiceInternal { } void SetEvent(uint32_t id) { switch (id) { - case 1: event = Ngs2VoicePlayEvent::Play; break; - case 2: event = Ngs2VoicePlayEvent::Stop; break; - case 4: event = Ngs2VoicePlayEvent::StopImm; break; - case 8: event = Ngs2VoicePlayEvent::Kill; break; - case 16: event = Ngs2VoicePlayEvent::Pause; break; - case 32: event = Ngs2VoicePlayEvent::Resume; break; + case 1: + if (state == Ngs2VoicePlayState::Empty || state == Ngs2VoicePlayState::Stopped) { + state = Ngs2VoicePlayState::Playing; + // Reserve the voice immediately; publish playback flags after rendering. + state_flags |= 1; + } + break; + case 2: + if (state == Ngs2VoicePlayState::Playing || state == Ngs2VoicePlayState::Paused) { + state = Ngs2VoicePlayState::Stopped; + } + break; + case 4: + case 8: state = Ngs2VoicePlayState::Empty; break; + case 16: + if (state == Ngs2VoicePlayState::Playing) { + state = Ngs2VoicePlayState::Paused; + } + break; + case 32: + if (state == Ngs2VoicePlayState::Paused) { + state = Ngs2VoicePlayState::Playing; + } + break; default: EXIT("unknown event_id: 0x%08" PRIx32 "\n", id); } } @@ -603,14 +753,7 @@ static_assert(sizeof(Ngs2WaveformBlock) == 40); static_assert(sizeof(Ngs2WaveformInfo) == 232); static uint32_t Ngs2GetStateFlags(const Ngs2VoiceInternal* voice) { - switch (voice->state) { - case Ngs2VoicePlayState::Empty: return 0; - case Ngs2VoicePlayState::Playing: return 0x3; - case Ngs2VoicePlayState::Paused: return 0x5; - case Ngs2VoicePlayState::Stopped: return 0xb; - } - - return 0; + return voice->state_flags; } static Ngs2SystemOption Ngs2DefaultSystemOption() { @@ -1235,92 +1378,83 @@ int KYTY_SYSV_ABI Ngs2RackUnlock(uintptr_t rack_handle) { return OK; } -static void Ngs2ApplyEvent(Ngs2VoiceInternal& voice) { - switch (voice.event) { - case Ngs2VoicePlayEvent::None: break; - case Ngs2VoicePlayEvent::Play: - if (voice.state == Ngs2VoicePlayState::Empty) { - voice.state = Ngs2VoicePlayState::Playing; - } - break; - case Ngs2VoicePlayEvent::Pause: - if (voice.state == Ngs2VoicePlayState::Playing) { - voice.state = Ngs2VoicePlayState::Paused; - } - break; - case Ngs2VoicePlayEvent::Resume: - if (voice.state == Ngs2VoicePlayState::Paused) { - voice.state = Ngs2VoicePlayState::Playing; - } - break; - case Ngs2VoicePlayEvent::Stop: - if (voice.state == Ngs2VoicePlayState::Playing) { - voice.state = Ngs2VoicePlayState::Stopped; - } - break; - case Ngs2VoicePlayEvent::StopImm: - case Ngs2VoicePlayEvent::Kill: voice.state = Ngs2VoicePlayState::Empty; break; - } - voice.event = Ngs2VoicePlayEvent::None; -} +struct Ngs2VoiceCallbackInfo { + uintptr_t data, voice; + uint32_t flag, reserved; + uintptr_t user; + const void* block_data; + size_t size; + uint32_t repeats, attributes; +}; +static_assert(sizeof(Ngs2VoiceCallbackInfo) == 56); static void Ngs2FinishBlock(Ngs2VoiceInternal& voice) { - const auto& block = voice.blocks.front(); - struct CallbackInfo { - uintptr_t data, voice; - uint32_t flag, reserved; - uintptr_t user; - const void* block_data; - size_t size; - uint32_t repeats, attributes; - } info {voice.callback_data, - reinterpret_cast(&voice), - 1, - 0, - block.info.user_data, - block.data, - block.info.data_size, - 0, - 0}; - static_assert(sizeof(CallbackInfo) == 56); - voice.blocks.pop_front(); - if (voice.blocks.empty() && !voice.accepts_blocks) { - voice.state = Ngs2VoicePlayState::Empty; + auto& block = voice.blocks.front(); + const bool repeat = block.info.num_repeats != 0; + voice.waveform_end = voice.WaveformData(); + if (repeat) { + if (block.info.num_repeats != UINT32_MAX) { + --block.info.num_repeats; + } + ++block.repeated_count; + block.cursor = 0; } - if (voice.callback != 0 && (voice.callback_flags & 1u) != 0) { - reinterpret_cast(voice.callback)(&info); + const Ngs2VoiceCallbackInfo info {voice.callback_data, + reinterpret_cast(&voice), + repeat ? 2u : 1u, + 0, + block.info.user_data, + block.data, + block.info.data_size, + block.repeated_count, + 0}; + if (!repeat) { + voice.blocks.pop_front(); + } + if (voice.callback != 0 && (voice.callback_flags & info.flag) != 0) { + reinterpret_cast(voice.callback)( + &info); } } static void Ngs2AdvancePcm(Ngs2VoiceInternal& voice) { const auto output_rate = uint64_t(voice.rack->ngs->option.sample_rate) << 32u; - while (!voice.blocks.empty() && voice.sample_phase >= output_rate) { + // Completion callbacks may interrupt advancement across multiple blocks. + while (voice.state == Ngs2VoicePlayState::Playing && !voice.blocks.empty() && + voice.sample_phase >= output_rate) { auto& block = voice.blocks.front(); const auto available = block.info.num_samples - block.cursor; const auto advance = std::min(voice.sample_phase / output_rate, uint64_t(available)); block.cursor += static_cast(advance); + voice.decoded_samples += advance; + voice.decoded_bytes += advance * voice.channels * sizeof(int16_t); voice.sample_phase -= advance * output_rate; if (block.cursor == block.info.num_samples) { Ngs2FinishBlock(voice); } } + if (voice.blocks.empty() && !voice.accepts_blocks) { + voice.sample_phase = 0; + } } static void Ngs2ConsumeSamples(Ngs2VoiceInternal& voice, uint32_t grain) { uint32_t output = 0; - while (output < grain && !voice.blocks.empty()) { + while (voice.state == Ngs2VoicePlayState::Playing && output < grain && !voice.blocks.empty()) { if (voice.decoder == nullptr) { Ngs2AdvancePcm(voice); - if (voice.blocks.empty()) { + if (voice.state != Ngs2VoicePlayState::Playing || voice.blocks.empty()) { break; } const auto& block = voice.blocks.front(); - const auto* pcm = reinterpret_cast(block.data) + - (block.info.num_skip_samples + block.cursor) * voice.channels; + const auto* pcm = reinterpret_cast(voice.WaveformData()); const auto* next = pcm; if (block.cursor + 1 < block.info.num_samples) { next += voice.channels; + } else if (block.info.num_repeats != 0) { + next = reinterpret_cast(block.data) + + block.info.num_skip_samples * voice.channels; } else if (voice.blocks.size() > 1) { const auto& next_block = voice.blocks[1]; next = reinterpret_cast(next_block.data) + @@ -1331,8 +1465,7 @@ static void Ngs2ConsumeSamples(Ngs2VoiceInternal& voice, uint32_t grain) { << 32u); for (uint32_t c = 0; c < voice.channels; ++c) { voice.samples[c * grain + output] = - (static_cast(pcm[c]) + - (static_cast(next[c]) - pcm[c]) * fraction) / + std::lerp(static_cast(pcm[c]), static_cast(next[c]), fraction) / 32768.0f; } voice.sample_phase += voice.sample_step; @@ -1369,7 +1502,9 @@ static void Ngs2ConsumeSamples(Ngs2VoiceInternal& voice, uint32_t grain) { } output += count; block.cursor += count; + voice.decoded_samples += count; if (block.cursor == block.info.num_samples) { + voice.decoded_bytes += block.info.data_size; Ngs2FinishBlock(voice); } } @@ -1389,8 +1524,22 @@ static void Ngs2RenderVoice(Ngs2VoiceInternal& voice, const std::vectortype == Ngs2RackType::CustomSampler) { + if (voice.rack->type == Ngs2RackType::Sampler || + voice.rack->type == Ngs2RackType::CustomSampler) { Ngs2ConsumeSamples(voice, grain); + // A playing stream can have no source data but still have a filter tail. + for (auto& filter: voice.filters) { + if (voice.has_samples || filter.HasHistory()) { + filter.Process(voice.samples, voice.channels, grain); + voice.has_samples = true; + } + } + if (voice.state == Ngs2VoicePlayState::Playing && voice.blocks.empty() && + !voice.accepts_blocks && + std::ranges::none_of(voice.filters, + [](const auto& filter) { return filter.HasHistory(); })) { + voice.state = Ngs2VoicePlayState::Empty; + } } for (auto* source: voices) { for (const auto& port: source->ports) { @@ -1472,11 +1621,8 @@ int KYTY_SYSV_ABI Ngs2SystemRender(uintptr_t system_handle, const Ngs2RenderBuff } auto* items = reinterpret_cast(rack + 1); for (uint32_t i = 0; i < rack->option.common.max_voices; ++i) { - Ngs2ApplyEvent(items[i]); items[i].rendered = false; - if (items[i].channels != 0) { - voices.push_back(items + i); - } + voices.push_back(items + i); } } } @@ -1487,6 +1633,9 @@ int KYTY_SYSV_ABI Ngs2SystemRender(uintptr_t system_handle, const Ngs2RenderBuff } const auto grain = ngs->option.num_grain_samples; for (auto* voice: voices) { + if (voice->channels == 0) { + continue; + } Ngs2RenderVoice(*voice, voices, grain); if (voice->rack->type != Ngs2RackType::Mastering || !voice->has_samples) { continue; @@ -1509,6 +1658,13 @@ int KYTY_SYSV_ABI Ngs2SystemRender(uintptr_t system_handle, const Ngs2RenderBuff } } } + for (auto* voice: voices) { + // No envelope is configured, so a release stop finishes in this render. + if (voice->state == Ngs2VoicePlayState::Stopped) { + voice->state = Ngs2VoicePlayState::Empty; + } + voice->state_flags = static_cast(voice->state); + } ++ngs->render_count; return OK; } @@ -1920,7 +2076,6 @@ int KYTY_SYSV_ABI Ngs2VoiceControl(uintptr_t voice_handle, const Ngs2VoiceParamH } break; } - case 0x1000: EXIT_NOT_IMPLEMENTED(voice->rack->type != Ngs2RackType::Sampler); break; case 0x2000: EXIT_NOT_IMPLEMENTED(voice->rack->type != Ngs2RackType::Submixer); break; case 0x2001: EXIT_NOT_IMPLEMENTED(voice->rack->type != Ngs2RackType::Reverb); break; case 0x3000: { @@ -1969,8 +2124,11 @@ int KYTY_SYSV_ABI Ngs2VoiceControl(uintptr_t voice_handle, const Ngs2VoiceParamH module.flags |= 2; break; } + case 0x1000: case 0x4001: { - EXIT_NOT_IMPLEMENTED(voice->rack->type != Ngs2RackType::CustomSampler); + EXIT_NOT_IMPLEMENTED( + voice->rack->type != + (rack_id == 0x1000 ? Ngs2RackType::Sampler : Ngs2RackType::CustomSampler)); switch (param->id & 0xffffu) { case 0: { const auto& format = @@ -1987,16 +2145,21 @@ int KYTY_SYSV_ABI Ngs2VoiceControl(uintptr_t voice_handle, const Ngs2VoiceParamH }; const auto& blocks = *reinterpret_cast(param); EXIT_NOT_IMPLEMENTED(!voice->accepts_blocks || - (blocks.flags != 0 && blocks.flags != 0x11 && + (blocks.flags != 0 && blocks.flags != 1 && + blocks.flags != 0x11 && blocks.flags != 0x4)); if (blocks.flags == 0x4) { voice->blocks.clear(); voice->sample_phase = 0; + voice->waveform_end = nullptr; } voice->accepts_blocks = (blocks.flags & 1u) != 0; for (uint32_t i = 0; i < blocks.count; ++i) { const auto& block = blocks.blocks[i]; - EXIT_NOT_IMPLEMENTED(block.num_repeats != 0 || block.num_samples == 0); + EXIT_NOT_IMPLEMENTED(block.num_samples == 0); + EXIT_NOT_IMPLEMENTED(block.num_repeats != 0 && + (voice->rack->type != Ngs2RackType::Sampler || + voice->decoder != nullptr)); EXIT_NOT_IMPLEMENTED( voice->decoder == nullptr && (uint64_t(block.num_skip_samples) + block.num_samples) * @@ -2006,6 +2169,16 @@ int KYTY_SYSV_ABI Ngs2VoiceControl(uintptr_t voice_handle, const Ngs2VoiceParamH } break; } + case 4: { + EXIT_NOT_IMPLEMENTED(voice->rack->type != Ngs2RackType::Sampler); + for (auto& block: voice->blocks) { + if (block.info.num_repeats != 0) { + block.info.num_repeats = 0; + break; + } + } + break; + } case 5: { const float ratio = *reinterpret_cast(param + 1); EXIT_NOT_IMPLEMENTED(!std::isfinite(ratio) || ratio < 0.0f || ratio > 4.0f || @@ -2014,6 +2187,21 @@ int KYTY_SYSV_ABI Ngs2VoiceControl(uintptr_t voice_handle, const Ngs2VoiceParamH static_cast(voice->sample_rate) * ratio * 4294967296.0)); break; } + case 0xa: { + EXIT_NOT_IMPLEMENTED(voice->rack->type != Ngs2RackType::Sampler); + EXIT_NOT_IMPLEMENTED(param->size != sizeof(Ngs2VoiceParamHeader) + + sizeof(Ngs2SamplerFilterParam)); + const auto& filter = + *reinterpret_cast(param + 1); + EXIT_NOT_IMPLEMENTED(filter.index >= + voice->rack->option.sampler.max_filters); + if (voice->filters.size() <= filter.index) { + voice->filters.resize(filter.index + 1); + } + voice->filters[filter.index].Configure( + filter, voice->rack->ngs->option.sample_rate, voice->channels); + break; + } default: EXIT("unsupported sampler control: 0x%08" PRIx32 "\n", param->id); } break; @@ -2141,9 +2329,12 @@ int KYTY_SYSV_ABI Ngs2VoiceGetState(uintptr_t voice_handle, Ngs2VoiceState* stat sampler->envelope_height = 1.0f; sampler->peak_height = 0.0f; sampler->reserved = 0; - sampler->num_decoded_samples = 0; - sampler->user_data = 0; - sampler->waveform_data = nullptr; + sampler->num_decoded_samples = voice->decoded_samples; + sampler->decoded_data_size = voice->decoded_bytes; + sampler->waveform_data = voice->WaveformData(); + if (!voice->blocks.empty()) { + sampler->user_data = voice->blocks.front().info.user_data; + } break; } default: EXIT("unknown type: %s\n", magic_enum::enum_name(voice->rack->type)); diff --git a/tests/Ngs2SamplerTests.cpp b/tests/Ngs2SamplerTests.cpp new file mode 100644 index 000000000..bd9b7c547 --- /dev/null +++ b/tests/Ngs2SamplerTests.cpp @@ -0,0 +1,689 @@ +// Exercise sampler controls and rendering without a host audio device. +#include "libs/ngs2.cpp" + +#include +#include +#include +#include + +using namespace Libs::Audio::Ngs2; + +namespace { + +void Check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "Ngs2SamplerTests: %s\n", message); + std::abort(); + } +} + +struct Fixture { + Ngs2Internal system; + Ngs2RackInternal rack {}; + Ngs2VoiceInternal voice; + + Fixture(uint32_t rate, uint32_t channels = 1, Ngs2RackType type = Ngs2RackType::Sampler) { + system.option.sample_rate = 48000; + rack.ngs = &system; + rack.type = type; + voice.rack = &rack; + struct Setup { + Ngs2VoiceParamHeader header {40, 0, 0x10000000}; + Ngs2WaveformFormat format; + uint32_t flags = 0, reserved = 0; + } setup; + setup.header.id = type == Ngs2RackType::Sampler ? 0x10000000 : 0x40010000; + setup.format = {0x12, channels, rate}; + Check(Ngs2VoiceControl(reinterpret_cast(&voice), &setup.header) == OK, + "sampler setup failed"); + voice.state = Ngs2VoicePlayState::Playing; + } + + void Queue(const int16_t* data, uint32_t frames, uint32_t flags, uint32_t repeats = 0, + uint32_t skip = 0) { + Ngs2WaveformBlock block { + 0, size_t(frames + skip) * voice.channels * sizeof(int16_t), repeats, skip, frames, 0, + 123}; + struct Blocks { + Ngs2VoiceParamHeader header {32, 0, 0x10000001}; + const void* data; + uint32_t flags, count; + const Ngs2WaveformBlock* blocks; + } param {{32, 0, 0x10000001}, data, flags, 1, &block}; + param.header.id = rack.type == Ngs2RackType::Sampler ? 0x10000001 : 0x40010001; + Check(Ngs2VoiceControl(reinterpret_cast(&voice), ¶m.header) == OK, + "sampler queue failed"); + } + + void Render(uint32_t frames) { + voice.rendered = false; + Ngs2RenderVoice(voice, {}, frames); + } +}; + +void TestStreamingResample() { + Fixture f(44100, 2); + std::vector pcm(441 * 2); + for (int i = 0; i < 441; ++i) { + pcm[i * 2] = static_cast(i * 32); + pcm[i * 2 + 1] = static_cast(-i * 32); + } + f.Queue(pcm.data(), 200, 1); + f.Queue(pcm.data() + 400, 241, 0); + for (uint32_t grain = 0; grain < 3; ++grain) { + f.Render(160); + for (uint32_t i = 0; i < 160; ++i) { + const float expected = std::min((grain * 160 + i) * 44100.0 / 48000.0, 440.0) / 1024.0; + Check(std::abs(f.voice.samples[i] - expected) < 0.00001f, + "resampling discontinuity across grain or block boundary"); + Check(std::abs(f.voice.samples[160 + i] + expected) < 0.00001f, + "stereo channels were mixed"); + } + } + Check(f.voice.blocks.empty(), "final streaming block did not finish"); + Check(f.voice.state == Ngs2VoicePlayState::Empty, "finished voice remained active"); + Ngs2SamplerVoiceState state {}; + Ngs2VoiceGetState(reinterpret_cast(&f.voice), &state.voice_state, sizeof(state)); + Check(state.num_decoded_samples == 441 && state.decoded_data_size == pcm.size() * 2, + "streaming progress does not match consumed source data"); +} + +void TestPitchLoopAndSkip() { + Fixture f(24000); + const int16_t pcm[] = {-30000, 8192, 16384}; + f.Queue(pcm, 2, 0, 1, 1); + struct Pitch { + Ngs2VoiceParamHeader header {16, 0, 0x10000005}; + float ratio = 2.0f; + uint32_t reserved = 0; + } pitch; + Ngs2VoiceControl(reinterpret_cast(&f.voice), &pitch.header); + f.Render(5); + const float expected[] = {0.25f, 0.5f, 0.25f, 0.5f, 0.0f}; + for (size_t i = 0; i < 5; ++i) { + Check(std::abs(f.voice.samples[i] - expected[i]) < 0.00001f, + "pitch, repeat count, or skipped samples incorrect"); + } +} + +void TestStarvationAndPause() { + Fixture f(48000); + const int16_t pcm[] = {16384, 8192}; + f.Queue(pcm, 2, 1); + f.Render(4); + Check(f.voice.state == Ngs2VoicePlayState::Playing, "stream starvation stopped voice"); + Check(f.voice.samples[2] == 0.0f, "starved output was not silent"); + f.Queue(pcm, 2, 0); + f.voice.SetEvent(16); + f.Render(2); + Check(!f.voice.has_samples && f.voice.blocks.front().cursor == 0, "paused voice consumed data"); + f.voice.SetEvent(32); + f.Render(2); + Check(f.voice.samples[0] == 0.5f && f.voice.samples[1] == 0.25f, + "stream failed to resume after starvation/pause"); +} + +void TestMonoRateAndRouting() { + Fixture f(22050); + std::vector pcm(441, 16384); + f.Queue(pcm.data(), 441, 0); + Ngs2RackInternal mastering_rack {}; + mastering_rack.type = Ngs2RackType::Mastering; + Ngs2VoiceInternal mastering; + mastering.rack = &mastering_rack; + mastering.channels = 2; + mastering.state = Ngs2VoicePlayState::Playing; + f.voice.matrices = {{0.5f, 0.25f}}; + f.voice.ports.push_back({&mastering, 0, 1.0f, 0}); + for (int i = 0; i < 3; ++i) { + mastering.rendered = false; + f.voice.rendered = false; + Ngs2RenderVoice(mastering, {&f.voice, &mastering}, 320); + Check(mastering.has_samples, "standard sampler did not reach mastering voice"); + for (uint32_t j = 0; j < 320; ++j) { + Check(mastering.samples[j] == 0.25f && mastering.samples[320 + j] == 0.125f, + "mono PCM routing matrix was not applied"); + } + } + Check(f.voice.decoded_samples == 441 && f.voice.blocks.empty(), + "22.05 kHz effect duration was incorrect"); +} + +void TestCustomPcmStillPlays() { + Fixture f(48000, 1, Ngs2RackType::CustomSampler); + const int16_t pcm[] = {8192, -16384}; + f.Queue(pcm, 2, 0); + f.Render(4); + Check(f.voice.samples[0] == 0.25f && f.voice.samples[1] == -0.5f && + f.voice.samples[2] == 0.0f && f.voice.blocks.empty(), + "custom PCM sampler regressed"); +} + +void TestSamplerReuseResetsRouting() { + for (auto type: {Ngs2RackType::Sampler, Ngs2RackType::CustomSampler}) { + Fixture f(48000, 1, type); + Ngs2RackInternal mastering_rack {}; + mastering_rack.type = Ngs2RackType::Mastering; + Ngs2VoiceInternal mastering; + mastering.rack = &mastering_rack; + mastering.channels = 2; + mastering.state = Ngs2VoicePlayState::Playing; + f.voice.ports.push_back({&mastering, 1, 0.25f, 0}); + f.voice.matrices = {{0.5f, 0.25f}}; + f.voice.SetupSampler({0x12, 2, 48000}); + Check(f.voice.ports.size() == 1 && f.voice.ports[0].dest == nullptr && + f.voice.ports[0].input == 0 && f.voice.ports[0].volume == 1.0f && + f.voice.ports[0].matrix == -1 && f.voice.matrices.size() == 1 && + f.voice.matrices[0].empty(), + "sampler setup retained routing from the previous channel layout"); + const int16_t pcm[] = {8192, 16384, -8192, -16384}; + f.Queue(pcm, 2, 0); + f.Render(1); + Check(!f.voice.has_samples && f.voice.blocks.front().cursor == 0, + "sampler setup started the new waveform without a play event"); + f.voice.ports[0].dest = &mastering; + f.voice.SetEvent(1); + f.voice.rendered = false; + Ngs2RenderVoice(mastering, {&f.voice, &mastering}, 2); + Check(mastering.samples == std::vector {0.25f, -0.25f, 0.5f, -0.5f}, + "reused stereo sampler did not restore default channel routing"); + } +} + +void TestPlayStateBeforeRender() { + Fixture f(48000); + const int16_t pcm[] = {8192, -16384}; + f.voice.state = Ngs2VoicePlayState::Empty; + f.Queue(pcm, 2, 0); + Ngs2VoiceEventParam event {{sizeof(Ngs2VoiceEventParam), 0, 6}, 1}; + const auto handle = reinterpret_cast(&f.voice); + Ngs2VoiceControl(handle, &event.header); + uint32_t flags = 0; + Ngs2VoiceGetStateFlags(handle, &flags); + Check(flags == 1, "play must reserve the voice before rendering starts playback"); + Ngs2SamplerVoiceState state {}; + Ngs2VoiceGetState(handle, &state.voice_state, sizeof(state)); + Check(state.voice_state.state_flags == flags && state.num_decoded_samples == 0, + "state queries disagreed or play consumed samples before render"); + f.Render(4); + Check(f.voice.samples[0] == 0.25f && f.voice.samples[1] == -0.5f, + "one-shot effect was dropped before its first render"); +} + +void TestStatePublication() { + Ngs2Internal system; + system.option = Ngs2DefaultSystemOption(); + Ngs2RackOptionUnion option {}; + Ngs2FillDefaultRackOption(0x1000, &option); + option.common.max_voices = 1; + alignas(Ngs2RackInternal) alignas(Ngs2VoiceInternal) + std::byte storage[sizeof(Ngs2RackInternal) + sizeof(Ngs2VoiceInternal)]; + Ngs2ContextBufferInfo buffer {storage, sizeof(storage)}; + uintptr_t rack_handle = 0; + const auto system_handle = reinterpret_cast(&system); + Check(Ngs2RackCreate(system_handle, 0x1000, &option.common, &buffer, &rack_handle) == OK, + "state publication rack creation failed"); + auto& voice = + *reinterpret_cast(reinterpret_cast(rack_handle) + 1); + voice.SetupSampler({0x12, 1, 48000}); + const auto handle = reinterpret_cast(&voice); + auto check_flags = [&](uint32_t expected) { + uint32_t flags = 0; + Ngs2SamplerVoiceState state {}; + Ngs2VoiceGetStateFlags(handle, &flags); + Ngs2VoiceGetState(handle, &state.voice_state, sizeof(state)); + Check(flags == expected && state.voice_state.state_flags == expected, + "voice state was published at the wrong render boundary"); + }; + struct Transition { + uint32_t event, before_render, after_render; + }; + const Transition transitions[] = {{1, 1, 3}, {16, 3, 5}, {32, 5, 3}, {2, 3, 0}, + {1, 1, 3}, {16, 3, 5}, {2, 5, 0}, {1, 1, 3}, + {4, 3, 0}, {1, 1, 3}, {8, 3, 0}}; + Ngs2RenderBufferInfo output {}; + for (const auto& transition: transitions) { + Ngs2VoiceEventParam event {{sizeof(Ngs2VoiceEventParam), 0, 6}, transition.event}; + Ngs2VoiceControl(handle, &event.header); + check_flags(transition.before_render); + Ngs2SystemRender(system_handle, &output, 1); + check_flags(transition.after_render); + } + Check(Ngs2RackDestroy(rack_handle, nullptr) == OK, "state publication rack cleanup failed"); +} + +void TestStopThenPlayBeforeRender() { + Fixture f(48000); + const int16_t pcm[] = {16384, -8192}; + f.Queue(pcm, 2, 0); + f.voice.SetEvent(2); + f.voice.SetEvent(1); + Check(Ngs2GetStateFlags(&f.voice) == 1, "stopped voice could not restart"); + f.voice.SetEvent(8); + f.voice.SetEvent(1); + Check(Ngs2GetStateFlags(&f.voice) == 1, "kill/play commands lost their ordering"); + f.Render(2); + Check(f.voice.samples[0] == 0.5f && f.voice.samples[1] == -0.25f, + "restarted voice produced no audio"); +} + +void SetLowPass(Fixture& f, float frequency = 1000.0f, uint64_t mask = 0) { + struct Param { + Ngs2VoiceParamHeader header {56, 0, 0x1000000a}; + Ngs2SamplerFilterParam filter {0, 1, 0, 1, 1000.0f, 0.70710678f, 1.0f, {}}; + } param; + param.filter.frequency = frequency; + param.filter.channel_mask = mask; + f.rack.option.sampler.max_filters = 8; + Check(Ngs2VoiceControl(reinterpret_cast(&f.voice), ¶m.header) == OK, + "filter ABI command failed"); +} + +void TestLowPassResponse() { + // A second-order Butterworth LPF has unity DC gain and -3 dB at cutoff. + for (double frequency: {100.0, 1000.0, 10000.0}) { + Fixture f(48000); + SetLowPass(f); + std::vector pcm(48000); + for (size_t i = 0; i < pcm.size(); ++i) { + pcm[i] = + static_cast(8192 * std::sin((2.0 * std::numbers::pi) * frequency * i / 48000)); + } + f.Queue(pcm.data(), static_cast(pcm.size()), 0); + f.Render(static_cast(pcm.size())); + double input = 0, output = 0; + for (size_t i = 24000; i < pcm.size(); ++i) { + input += std::pow(pcm[i] / 32768.0, 2); + output += std::pow(f.voice.samples[i], 2); + } + const double ratio = std::sqrt(output / input); + if (frequency == 100) { + Check(std::abs(ratio - 1.0) < 0.002, "low-pass lost bass"); + } + if (frequency == 1000) { + Check(std::abs(ratio - std::sqrt(0.5)) < 0.002, "cutoff gain incorrect"); + } + if (frequency == 10000) { + Check(ratio < 0.01, "low-pass did not reject high frequencies"); + } + } +} + +void TestFilterHistoryAndChannels() { + Fixture whole(48000, 2), split(48000, 2); + SetLowPass(whole, 1000, 1); + SetLowPass(split, 1000, 1); + std::vector pcm(256 * 2, 0); + pcm[0] = pcm[1] = 16384; + whole.Queue(pcm.data(), 256, 0); + split.Queue(pcm.data(), 32, 1); + split.Queue(pcm.data() + 64, 224, 0); + whole.Render(256); + for (uint32_t g = 0; g < 8; ++g) { + // Games resend unchanged parameters; this must not reset the delay line. + SetLowPass(split, 1000, 1); + split.Render(32); + for (uint32_t c = 0; c < 2; ++c) { + for (uint32_t i = 0; i < 32; ++i) { + Check(std::abs(split.voice.samples[c * 32 + i] - + whole.voice.samples[c * 256 + g * 32 + i]) < 1e-7, + "filter history changed at a grain/block/control boundary"); + } + } + } + Check(whole.voice.samples[0] == 0.5f && whole.voice.samples[1] == 0.0f && + whole.voice.samples[256] > 0.0f && whole.voice.samples[256] < 0.5f, + "channel mask must bypass selected channels and filter the others"); + Fixture stereo(48000, 2); + SetLowPass(stereo); + pcm[1] = 0; + stereo.Queue(pcm.data(), 256, 0); + stereo.Render(256); + for (size_t i = 256; i < 512; ++i) { + Check(stereo.voice.samples[i] == 0.0f, "filter leaked history between channels"); + } + stereo.voice.SetupSampler({0x12, 2, 48000}); + Check(stereo.voice.filters.empty(), "reused voice retained old filters"); +} + +void TestFilterUsesOutputRate() { + Fixture f(22050); + SetLowPass(f, 6833.0f); + const auto& filter = f.voice.filters[0]; + // Rear filtering operates at the 48 kHz system rate, not the PCM source rate. + const double w = (2.0 * std::numbers::pi) * 6833 / 48000; + const double expected = (1 - std::cos(w)) / (2 * (1 + std::sin(w) / (2 * double(0.70710678f)))); + Check(std::abs(filter.b0 - expected) < 1e-10, "rear filter used source sample rate"); + SetLowPass(f, 48000); + const int16_t pcm[] = {16384, 16384}; + f.Queue(pcm, 2, 0); + f.Render(2); + Check(f.voice.samples[0] == 0.5f && f.voice.samples[1] == 0.5f, + "above-Nyquist cutoff was unstable"); +} + +void TestFilterControlLayout() { + Fixture f(48000, 2); + f.rack.option.sampler.max_filters = 8; + // A 56-byte control command with nonzero trailing padding. + alignas(8) uint32_t words[] = {56, 0x1000000a, 0, 1, 0, 0, 1, + 0x45d58800, 0x3fd55555, 0x3f800000, 0, 0, 0, 0x13f}; + Ngs2VoiceControl(reinterpret_cast(&f.voice), + reinterpret_cast(words)); + Check(f.voice.filters.size() == 1 && f.voice.filters[0].enabled, + "FCQ filter command was not recognized"); + const double omega = (2.0 * std::numbers::pi) * 6833.0 / 48000.0; + const double q = static_cast(std::bit_cast(0x3fd55555u)); + const double expected = (1 - std::cos(omega)) / (2 * (1 + std::sin(omega) / (2 * q))); + Check(std::abs(f.voice.filters[0].b0 - expected) < 1e-10, + "FCQ fields were read at the wrong offsets"); + words[4] = 1; // Bypass channel 0 in the low half of the 64-bit mask. + words[5] = 1; // Bypass channel 32 in the high half. + Fixture wide(48000, 33); + wide.rack.option.sampler.max_filters = 8; + Ngs2VoiceControl(reinterpret_cast(&wide.voice), + reinterpret_cast(words)); + const std::vector pcm(33, 16384); + wide.Queue(pcm.data(), 1, 1); + wide.Render(1); + Check(wide.voice.samples[0] == 0.5f && wide.voice.samples[32] == 0.5f && + std::abs(wide.voice.samples[1] - expected * 0.5) < 1e-7, + "channel mask was not decoded as a 64-bit bypass mask"); + words[6] = 0; // OFF ignores unused FCQ values and clears the old delay line. + words[7] = 0x7fc00000; + Ngs2VoiceControl(reinterpret_cast(&wide.voice), + reinterpret_cast(words)); + Check(!wide.voice.filters[0].enabled && wide.voice.filters[0].history.empty() && + !wide.voice.filters[0].warned, + "filter OFF was treated as an unsupported FCQ configuration"); + wide.Queue(pcm.data(), 1, 1); + wide.Render(1); + Check(wide.voice.samples[1] == 0.5f, "filter OFF did not bypass audio"); + SetLowPass(wide, 0); + wide.Queue(pcm.data(), 1, 1); + wide.Render(1); + Check(wide.voice.samples[1] == 0.0f, "zero-Hz low-pass leaked source audio"); +} + +struct LoopCallbacks { + std::vector> events; + bool pause = false; +}; + +void KYTY_SYSV_ABI RecordLoopCallback(const Ngs2VoiceCallbackInfo* info) { + auto& log = *reinterpret_cast(info->data); + log.events.emplace_back(info->flag, info->repeats); + Check(info->user == 123 && info->attributes == 0, "loop callback metadata changed"); + if (log.pause && info->flag == 2) { + reinterpret_cast(info->voice)->SetEvent(16); + } +} + +void TestLoopCallbacksAndExit() { + const int16_t pcm[] = {8192, 16384, 24576}; + Fixture f(48000); + LoopCallbacks log; + f.voice.callback = reinterpret_cast(&RecordLoopCallback); + f.voice.callback_data = reinterpret_cast(&log); + f.voice.callback_flags = 3; + f.Queue(pcm, 2, 0, 2); + f.Render(6); + Check(log.events == std::vector> {{2, 1}, {2, 2}, {1, 2}}, + "loop and completion callbacks lost the completed repeat count"); + Check(f.voice.blocks.empty(), "finite loop did not complete"); + + Fixture active(48000); + log.events.clear(); + log.pause = true; + active.voice.callback = reinterpret_cast(&RecordLoopCallback); + active.voice.callback_data = reinterpret_cast(&log); + active.voice.callback_flags = 2; + active.Queue(pcm, 2, 1, UINT32_MAX); + active.Queue(pcm + 2, 1, 0); + active.Render(6); + Check(log.events.size() == 1 && active.voice.decoded_samples == 2 && + active.voice.blocks.front().cursor == 0 && active.voice.samples[2] == 0, + "loop callback pause consumed another iteration"); + Ngs2VoiceParamHeader exit_loop {sizeof(Ngs2VoiceParamHeader), 0, 0x10000004}; + Ngs2VoiceControl(reinterpret_cast(&active.voice), &exit_loop); + active.voice.SetEvent(32); + active.Render(3); + Check(active.voice.samples == std::vector {0.25f, 0.5f, 0.75f} && + active.voice.blocks.empty() && log.events.size() == 1, + "exiting infinite loop lost its final iteration or ignored callback flags"); + + Fixture pending(48000); + pending.Queue(pcm, 1, 1); + pending.Queue(pcm + 1, 1, 1, UINT32_MAX); + pending.Queue(pcm + 2, 1, 0); + Ngs2VoiceControl(reinterpret_cast(&pending.voice), &exit_loop); + pending.Render(3); + Check(pending.voice.samples == std::vector {0.25f, 0.5f, 0.75f} && + pending.voice.blocks.empty(), + "exiting a pending loop skipped its first iteration"); + Ngs2VoiceControl(reinterpret_cast(&pending.voice), &exit_loop); +} + +void TestWaveformReadAddress() { + Fixture f(48000, 2); + const int16_t pcm[] = {-100, -100, 8192, 4096, 16384, 8192}; + f.Queue(pcm, 2, 1, 1, 1); + const auto next_data = [&]() { + Ngs2SamplerVoiceState state {}; + Ngs2VoiceGetState(reinterpret_cast(&f.voice), &state.voice_state, sizeof(state)); + return state.waveform_data; + }; + Check(next_data() == pcm + 2, "read address ignored skipped source samples"); + f.Render(1); + Check(next_data() == pcm + 4, "read address did not advance by a stereo frame"); + f.Render(1); + Check(next_data() == pcm + 2, "read address did not wrap at a loop boundary"); + f.Render(2); + Check(f.voice.blocks.empty() && next_data() == pcm + 6, + "starved read address did not preserve the consumed waveform end"); + f.Queue(pcm + 2, 2, 0); + Check(next_data() == pcm + 2, "refilled read address retained the previous waveform end"); + f.Render(2); + Check(next_data() == pcm + 6, "completed read address did not preserve waveform end"); + f.voice.SetupSampler({0x12, 2, 48000}); + Check(next_data() == nullptr, "setup retained an old waveform address"); +} + +struct CallbackEvent { + Ngs2VoiceInternal* voice; + uint32_t event; + uint32_t calls = 0; +}; + +void KYTY_SYSV_ABI ApplyCallbackEvent(const Ngs2VoiceCallbackInfo* info) { + auto& context = *reinterpret_cast(info->data); + if (++context.calls == 1) { + context.voice->SetEvent(context.event); + } +} + +void TestCallbackStopsConsumption() { + for (auto rack: {Ngs2RackType::Sampler, Ngs2RackType::CustomSampler}) { + for (uint32_t event: {2u, 4u, 8u, 16u}) { + Fixture f(48000, 1, rack); + const int16_t pcm[] = {8192, 16384}; + f.Queue(pcm, 1, 1); + f.Queue(pcm + 1, 1, 1); + CallbackEvent context {&f.voice, event}; + f.voice.callback = reinterpret_cast(&ApplyCallbackEvent); + f.voice.callback_data = reinterpret_cast(&context); + f.voice.callback_flags = 1; + f.Render(2); + Check(f.voice.state != Ngs2VoicePlayState::Playing && context.calls == 1, + "completion callback failed to interrupt playback"); + Check(f.voice.samples[0] == 0.25f && f.voice.samples[1] == 0.0f && + f.voice.decoded_samples == 1 && f.voice.blocks.size() == 1 && + f.voice.blocks.front().cursor == 0, + "callback pause/stop consumed the next queued block"); + if (event == 16) { + f.voice.SetEvent(32); + f.Render(1); + Check(f.voice.samples[0] == 0.5f && f.voice.decoded_samples == 2, + "callback pause failed to preserve queued audio for resume"); + } + } + } +} + +void TestCallbackPauseDuringLargeStep() { + Fixture f(48000); + const int16_t pcm[] = {8192, 16384, 24576, 4096}; + f.Queue(pcm, 1, 1); + f.Queue(pcm + 1, 1, 1); + f.Queue(pcm + 2, 2, 1); + f.voice.sample_step = uint64_t(120000) << 32u; + CallbackEvent context {&f.voice, 16}; + f.voice.callback = reinterpret_cast(&ApplyCallbackEvent); + f.voice.callback_data = reinterpret_cast(&context); + f.voice.callback_flags = 1; + f.Render(2); + Check(context.calls == 1 && f.voice.decoded_samples == 1 && + f.voice.sample_phase == (uint64_t(72000) << 32u), + "pause advanced across additional blocks in a large resampling step"); + f.voice.SetEvent(32); + f.Render(1); + Check(std::abs(f.voice.samples[0] - 0.4375f) < 1e-7f && context.calls == 3, + "resume lost fractional position or read past the next short block"); +} + +void TestUpstreamCustomSamplerControls() { + Fixture f(24000, 1, Ngs2RackType::CustomSampler); + const int16_t pcm[] = {8192, 16384, 24576, 4096}; + f.Queue(pcm, 4, 1); + f.Render(1); + Check(f.voice.samples[0] == 0.25f, "custom sampler source rate was rejected"); + f.Queue(pcm + 2, 2, 4); + f.Render(1); + Check(f.voice.samples[0] == 0.75f && f.voice.blocks.size() == 1, + "replacement blocks retained old data or fractional position"); + struct Pitch { + Ngs2VoiceParamHeader header {16, 0, 0x40010005}; + float ratio = 0.0f; + uint32_t reserved = 0; + } pitch; + const auto handle = reinterpret_cast(&f.voice); + Ngs2VoiceControl(handle, &pitch.header); + f.Render(2); + Check(f.voice.samples[0] == 0.4375f && f.voice.samples[1] == 0.4375f, + "zero custom sampler pitch advanced playback"); + pitch.ratio = 4.0f; + Ngs2VoiceControl(handle, &pitch.header); + f.Render(1); + Check(f.voice.blocks.empty(), "custom sampler pitch did not advance playback"); + f.rack.option.custom_sampler.custom_rack_option.state_size = + sizeof(Ngs2CustomSamplerVoiceState); + Ngs2CustomSamplerVoiceState state {}; + Check(Ngs2VoiceGetState(handle, &state.voice_state, sizeof(state)) == OK && + state.voice_state.state_flags == Ngs2GetStateFlags(&f.voice), + "custom sampler state layout regressed"); +} + +void TestFilterTailDuringStarvation() { + Fixture reference(48000), stream(48000); + SetLowPass(reference); + SetLowPass(stream); + std::vector pcm(128, 0); + pcm[0] = pcm[64] = 16384; + reference.Queue(pcm.data(), 128, 1); + reference.Render(128); + stream.Queue(pcm.data(), 1, 1); + stream.Render(1); + Check(stream.voice.samples[0] == reference.voice.samples[0], "initial filter sample differs"); + stream.Render(63); + Check(stream.voice.has_samples, "starved filter tail was not available for routing"); + for (size_t i = 0; i < 63; ++i) { + Check(std::abs(stream.voice.samples[i] - reference.voice.samples[i + 1]) < 1e-7f, + "filter tail changed at a starvation/grain boundary"); + } + stream.Queue(pcm.data() + 64, 64, 1); + stream.Render(64); + for (size_t i = 0; i < 64; ++i) { + Check(std::abs(stream.voice.samples[i] - reference.voice.samples[i + 64]) < 1e-7f, + "refilled stream reused stale filter history"); + } + stream.Render(8192); + stream.Render(32); + Check(!stream.voice.has_samples && !stream.voice.filters[0].HasHistory(), + "fully decayed filter kept a silent stream active"); +} + +void TestResamplingPhaseAfterStarvation() { + for (auto rack: {Ngs2RackType::Sampler, Ngs2RackType::CustomSampler}) { + for (uint32_t rate: {44100u, 96000u}) { + Fixture reference(rate, 1, rack), stream(rate, 1, rack); + const int16_t pcm[] = {0, 0, 16384, 8192}; + const uint32_t frames = rate == 44100 ? 2 : 1; + reference.Queue(pcm, 4, 0); + stream.Queue(pcm, 1, 1); + reference.Render(frames); + stream.Render(frames); + Check(stream.voice.blocks.empty() && stream.voice.state == Ngs2VoicePlayState::Playing, + "open stream did not enter starvation"); + stream.Render(8); + Check(!stream.voice.has_samples, "starved stream produced audio without a filter"); + stream.Queue(pcm + 1, 3, 0); + reference.Render(1); + stream.Render(1); + Check(std::abs(stream.voice.samples[0] - reference.voice.samples[0]) < 1e-7f, + "stream refill lost resampling phase"); + Check(stream.voice.decoded_samples == reference.voice.decoded_samples, + "stream refill lost pending source advancement"); + stream.voice.SetupSampler({0x12, 1, rate}); + Check(stream.voice.sample_phase == 0, "sampler setup retained old resampling phase"); + } + } +} + +void TestFiniteFilterTail() { + Fixture whole(48000), split(48000); + SetLowPass(whole); + SetLowPass(split); + const int16_t impulse = 16384; + whole.Queue(&impulse, 1, 0); + split.Queue(&impulse, 1, 0); + whole.Render(256); + for (size_t i = 0; i < whole.voice.samples.size(); ++i) { + split.Render(1); + Check(std::abs(split.voice.samples[0] - whole.voice.samples[i]) < 1e-7f, + "finite filter tail changed with render grain size"); + } + Check(split.voice.state == Ngs2VoicePlayState::Playing, + "finite voice stopped before its filter tail decayed"); + split.Render(8192); + Check(split.voice.state == Ngs2VoicePlayState::Empty, + "finite voice remained active after its filter tail decayed"); + split.Render(32); + Check(!split.voice.has_samples, "completed finite filter tail still produced samples"); +} + +} // namespace + +int main() { + TestResamplingPhaseAfterStarvation(); + TestSamplerReuseResetsRouting(); + TestLoopCallbacksAndExit(); + TestWaveformReadAddress(); + TestFilterTailDuringStarvation(); + TestFiniteFilterTail(); + TestLowPassResponse(); + TestFilterHistoryAndChannels(); + TestFilterUsesOutputRate(); + TestFilterControlLayout(); + TestUpstreamCustomSamplerControls(); + TestCallbackStopsConsumption(); + TestCallbackPauseDuringLargeStep(); + TestStreamingResample(); + TestPitchLoopAndSkip(); + TestStarvationAndPause(); + TestMonoRateAndRouting(); + TestCustomPcmStillPlays(); + TestPlayStateBeforeRender(); + TestStatePublication(); + TestStopThenPlayBeforeRender(); + std::puts("Ngs2SamplerTests: all cases passed"); +}