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"); +}