From c528324d27bb6e6094d9efaf11fdbb88d27d8891 Mon Sep 17 00:00:00 2001 From: Devran Cosmo Uenal Date: Wed, 28 Oct 2026 21:31:16 +0100 Subject: [PATCH] audio: let the AJM Opus decoder write part of a packet (#1914) ajm: buffer and resample Opus output --- CMakeLists.txt | 9 + src/libs/ajm.cpp | 65 +++++-- src/libs/ajm/decoder.h | 23 ++- src/libs/ajm/resampler.h | 182 +++++++++++++++++++ tests/AjmResampleTests.cpp | 360 +++++++++++++++++++++++++++++++++++++ 5 files changed, 621 insertions(+), 18 deletions(-) create mode 100644 src/libs/ajm/resampler.h create mode 100644 tests/AjmResampleTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d3f2354a..059ae1945 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -522,6 +522,13 @@ add_executable(ngs2_sampler_tests EXCLUDE_FROM_ALL target_link_libraries(ngs2_sampler_tests common fmt::fmt LibAtrac9) target_include_directories(ngs2_sampler_tests PRIVATE ${inc_headers}) +add_executable(ajm_resample_tests EXCLUDE_FROM_ALL + "${KYTY_TESTS_DIR}/AjmResampleTests.cpp" + "${KYTY_SOURCE_DIR}/loader/timer.cpp" +) +target_link_libraries(ajm_resample_tests common fmt::fmt FFmpeg::ffmpeg LibAtrac9 opus) +target_include_directories(ajm_resample_tests PRIVATE ${inc_headers}) + add_executable(pad_haptics_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/PadHapticsTests.cpp" ) @@ -668,6 +675,7 @@ if(BUILD_TESTING) add_test(NAME audio_out2_port COMMAND $) add_test(NAME audio_out_timing COMMAND $) add_test(NAME ngs2_sampler COMMAND $) + add_test(NAME ajm_resample COMMAND $) add_test(NAME pad_haptics COMMAND $) add_test(NAME controller_settings COMMAND $) add_test(NAME host_input COMMAND $) @@ -762,6 +770,7 @@ if(BUILD_TESTING) audio_out2_port_tests audio_out_timing_tests ngs2_sampler_tests + ajm_resample_tests pad_haptics_tests controller_settings_tests host_input_tests diff --git a/src/libs/ajm.cpp b/src/libs/ajm.cpp index 8169fddfb..5b3653d03 100644 --- a/src/libs/ajm.cpp +++ b/src/libs/ajm.cpp @@ -6,6 +6,7 @@ #include "libs/ajm/decoder.h" #include "libs/ajm/mp3_decoder.h" #include "libs/ajm/opus_decoder.h" +#include "libs/ajm/resampler.h" #include "libs/audio.h" #include "libs/errno.h" #include "libs/libs.h" @@ -63,12 +64,6 @@ struct AjmSidebandMFrame { uint32_t reserved; }; -struct AjmSidebandResampleInfo { - float ratio; - int32_t num_samples; - uint32_t reserved[8]; -}; - struct AjmGetStatisticsResult { AjmSidebandResult result; float engine_usage_batch; @@ -104,6 +99,8 @@ constexpr uint64_t AJM_FLAG_RUN_GET_CODEC_INFO = 1ull << 11u; constexpr uint64_t AJM_FLAG_RUN_MULTIPLE_FRAMES = 1ull << 12u; constexpr uint64_t AJM_FLAG_CONTROL_RESET = 1ull << 13u; constexpr uint64_t AJM_FLAG_CONTROL_INITIALIZE = 1ull << 14u; +constexpr uint64_t AJM_FLAG_CONTROL_RESAMPLE = 1ull << 15u; +constexpr uint64_t AJM_FLAG_CONTROL_RESAMPLE_EX = 1ull << 16u; constexpr uint64_t AJM_FLAG_SIDEBAND_RESAMPLE_INFO = 1ull << 44u; constexpr uint64_t AJM_FLAG_SIDEBAND_GAPLESS = 1ull << 45u; constexpr uint64_t AJM_FLAG_SIDEBAND_FORMAT = 1ull << 46u; @@ -177,6 +174,7 @@ struct AjmInstanceState { uint64_t flags = 0; AjmGaplessState gapless; std::unique_ptr decoder; + AjmResampler resampler; }; static std::mutex g_ajm_instances_mutex; @@ -358,7 +356,7 @@ static void AjmWriteSideband(uint64_t flags, void* sideband_output, size_t sideb offset + AJM_SIDEBAND_RESAMPLE_INFO_SIZE <= sideband_output_size) { auto* info = reinterpret_cast( static_cast(sideband_output) + offset); - info->ratio = 1.0f; + *info = decode_result.resample_info; offset += AJM_SIDEBAND_RESAMPLE_INFO_SIZE; } @@ -425,6 +423,12 @@ static AjmDecodeResult AjmDecodeInstance(uint32_t instance, const void* input, s return result; } + // The resampler requires one complete decoded packet per decoder call. + if (state->codec == static_cast(AjmCodec::DecOpus) && + (state->flags & AJM_INSTANCE_FLAG_RESAMPLE) != 0) { + return state->resampler.Decode(*state->decoder, state->flags, input, input_size, output, + output_size, multiple_frames, &state->gapless); + } return state->decoder->Decode(input, input_size, output, output_size, multiple_frames, &state->gapless); } @@ -514,6 +518,32 @@ static AjmDecodeResult AjmControlInstance(uint32_t instance, uint64_t flags, sideband_input = static_cast(sideband_input) + AJM_SIDEBAND_GAPLESS_SIZE; sideband_input_size -= AJM_SIDEBAND_GAPLESS_SIZE; } + for (const auto flag : {AJM_FLAG_CONTROL_RESAMPLE, AJM_FLAG_CONTROL_RESAMPLE_EX}) { + if ((flags & flag) == 0) { + continue; + } + const bool extended = flag == AJM_FLAG_CONTROL_RESAMPLE_EX; + const size_t size = extended ? 12 : 8; + if (sideband_input == nullptr || sideband_input_size < size || + (state->flags & AJM_INSTANCE_FLAG_RESAMPLE) == 0) { + result.result = AJM_RESULT_INVALID_PARAMETER; + return result; + } + float ratio, change = 0; + uint32_t resample_flags; + const auto* data = static_cast(sideband_input); + std::memcpy(&ratio, data, sizeof(ratio)); + if (extended) { + std::memcpy(&change, data + sizeof(ratio), sizeof(change)); + } + std::memcpy(&resample_flags, data + size - sizeof(resample_flags), sizeof(resample_flags)); + result.result = state->resampler.SetParameters(ratio, change, resample_flags); + if (result.result != OK) { + return result; + } + sideband_input = data + size; + sideband_input_size -= size; + } if ((flags & AJM_FLAG_CONTROL_RESET) != 0) { state->gapless.Reset(); decoder->Reset(); @@ -524,6 +554,7 @@ static AjmDecodeResult AjmControlInstance(uint32_t instance, uint64_t flags, result = decoder->Initialize(sideband_input, sideband_input_size); } + result.resample_info = state->resampler.GetInfo(); return result; } @@ -572,6 +603,7 @@ static AjmSidebandFormat AjmGetInstanceFormat(uint32_t instance, if (out_result != nullptr) { *out_result = state->decoder->MakeResult(); out_result->format = state->decoder->GetFormat(); + out_result->resample_info = state->resampler.GetInfo(); } return state->decoder->GetFormat(); } @@ -921,7 +953,13 @@ int KYTY_SYSV_ABI AjmBatchJobSetResampleParameters(AjmBatchInfo* info, uint32_t float ratio, uint32_t flags, void* result) { PRINT_NAME(); EXIT_NOT_IMPLEMENTED(info == nullptr); - AjmWriteResult(result, AJM_SIDEBAND_RESULT_SIZE); + const struct { + float ratio; + uint32_t flags; + } params {ratio, flags}; + const auto decoded = AjmControlInstance(instance, AJM_FLAG_CONTROL_RESAMPLE, ¶ms, + sizeof(params), nullptr, nullptr); + AjmWriteResult(result, AJM_SIDEBAND_RESULT_SIZE, decoded); LOGF("\t instance = 0x%08" PRIx32 ", ratio = %f, flags = 0x%08" PRIx32 "\n", instance, static_cast(ratio), flags); return AjmAppendJob(info, AJM_JOB_SET_RESAMPLE_PARAMETERS_SIZE, "set-resample"); @@ -933,7 +971,14 @@ int KYTY_SYSV_ABI AjmBatchJobSetResampleParametersEx(AjmBatchInfo* info, uint32_ void* result) { PRINT_NAME(); EXIT_NOT_IMPLEMENTED(info == nullptr); - AjmWriteResult(result, AJM_SIDEBAND_RESULT_SIZE); + const struct { + float ratio; + float change; + uint32_t flags; + } params {ratio_start, ratio_change_per_sample, flags}; + const auto decoded = AjmControlInstance(instance, AJM_FLAG_CONTROL_RESAMPLE_EX, ¶ms, + sizeof(params), nullptr, nullptr); + AjmWriteResult(result, AJM_SIDEBAND_RESULT_SIZE, decoded); LOGF("\t instance = 0x%08" PRIx32 ", ratio_start = %f, ratio_change = %f, flags = 0x%08" PRIx32 "\n", instance, static_cast(ratio_start), static_cast(ratio_change_per_sample), @@ -952,7 +997,7 @@ int KYTY_SYSV_ABI AjmBatchJobGetResampleInfo(AjmBatchInfo* info, uint32_t instan if (result != nullptr) { auto* resample = reinterpret_cast(static_cast(result) + AJM_SIDEBAND_RESULT_SIZE); - resample->ratio = 1.0f; + *resample = decode_result.resample_info; } LOGF("\t instance = 0x%08" PRIx32 "\n", instance); return AjmAppendJob(info, AJM_JOB_RUN_SIZE, "get-resample-info"); diff --git a/src/libs/ajm/decoder.h b/src/libs/ajm/decoder.h index 12ed70e23..b02f32ef4 100644 --- a/src/libs/ajm/decoder.h +++ b/src/libs/ajm/decoder.h @@ -56,15 +56,22 @@ enum class AjmSampleEncoding : uint32_t { Float = 2, }; +struct AjmSidebandResampleInfo { + float ratio = 1.0f; + int32_t num_samples = 0; + uint32_t reserved[8] {}; +}; + struct AjmDecodeResult { - int32_t result = OK; - int32_t internal_result = OK; - size_t input_consumed = 0; - size_t output_written = 0; - uint64_t total_decoded_samples = 0; - uint32_t frames = 0; - uint32_t frames_per_packet = 0; - AjmSidebandFormat format {}; + int32_t result = OK; + int32_t internal_result = OK; + size_t input_consumed = 0; + size_t output_written = 0; + uint64_t total_decoded_samples = 0; + uint32_t frames = 0; + uint32_t frames_per_packet = 0; + AjmSidebandFormat format {}; + AjmSidebandResampleInfo resample_info {}; }; constexpr int32_t AJM_RESULT_NOT_INITIALIZED = 0x00000001; diff --git a/src/libs/ajm/resampler.h b/src/libs/ajm/resampler.h new file mode 100644 index 000000000..7bb332305 --- /dev/null +++ b/src/libs/ajm/resampler.h @@ -0,0 +1,182 @@ +#pragma once + +#include "libs/ajm/decoder.h" + +#include +#include +#include +#include + +namespace Libs::Audio::Ajm { + +constexpr uint64_t AJM_INSTANCE_FLAG_GAPLESS_LOOP = 1ull << 10u; +constexpr uint64_t AJM_INSTANCE_FLAG_RESAMPLE = 1ull << 12u; +constexpr uint64_t AJM_INSTANCE_FLAG_SEAMLESS_LOOP = 1ull << 13u; +constexpr uint32_t AJM_RESAMPLE_FLAG_RESET = 1u; +constexpr uint32_t AJM_RESAMPLE_FLAG_END = 2u; +constexpr uint32_t AJM_RESAMPLE_FLAG_IGNORE_RATIO_PARAMETER = 8u; + +class AjmResampler { +public: + int32_t SetParameters(float ratio, float change, uint32_t flags) { + if ((flags & AJM_RESAMPLE_FLAG_IGNORE_RATIO_PARAMETER) == 0 && + (!std::isfinite(ratio) || ratio < 0 || ratio > 4 || !std::isfinite(change))) { + return AJM_RESULT_INVALID_PARAMETER; + } + if ((flags & AJM_RESAMPLE_FLAG_RESET) != 0) { + m_pcm.clear(); + m_position = 0; + m_end = false; + } + if ((flags & AJM_RESAMPLE_FLAG_IGNORE_RATIO_PARAMETER) == 0) { + m_ratio = ratio; + m_change = std::abs(change) < 0.000001f ? 0 : change; + } + m_end |= (flags & AJM_RESAMPLE_FLAG_END) != 0; + return OK; + } + + [[nodiscard]] AjmSidebandResampleInfo GetInfo() const { + AjmSidebandResampleInfo info; + info.ratio = static_cast(m_ratio); + info.num_samples = m_end && m_position >= Samples() + ? -1 + : static_cast(std::ceil(Samples() - m_position)); + return info; + } + + AjmDecodeResult Decode(AjmDecoder& decoder, uint64_t flags, const void* input, + size_t input_size, void* output, size_t output_size, + bool multiple_frames, AjmGaplessState* gapless) { + auto result = decoder.MakeResult(); + if (result.format.channel_num == 0) { + return decoder.Decode(input, input_size, output, output_size, multiple_frames, gapless); + } + bool stop_decoding = false; + for (;;) { + const auto format = decoder.GetFormat(); + const auto encoding = static_cast(format.sample_encoding); + const auto sample_bytes = format.channel_num * AjmBytesPerSample(encoding); + if (!m_pcm.empty() && sample_bytes != m_sample_bytes) { + result.result = AJM_RESULT_INVALID_PARAMETER; + break; + } + if (output == nullptr || output_size - result.output_written < sample_bytes) { + if (m_position < Samples() || result.input_consumed < input_size) { + result.result |= AJM_RESULT_NOT_ENOUGH_ROOM; + } + break; + } + + const auto index = static_cast(m_position); + const auto fraction = m_position - static_cast(index); + const bool need_next = fraction != 0 && index + 1 >= Samples(); + if (index >= Samples() || need_next) { + Compact(); + if (!stop_decoding && input != nullptr && result.input_consumed < input_size && + (gapless == nullptr || !gapless->IsEnd())) { + // A decode job can produce up to six 20 ms Opus frames in one packet. + const auto channels = std::max(format.channel_num, flags & 0x7fu); + m_frame.resize(5760 * channels * AjmBytesPerSample(encoding)); + auto decoded = + decoder.Decode(static_cast(input) + result.input_consumed, + input_size - result.input_consumed, m_frame.data(), + m_frame.size(), false, gapless); + result.input_consumed += decoded.input_consumed; + result.frames += decoded.frames; + result.frames_per_packet = decoded.frames_per_packet; + result.total_decoded_samples = decoded.total_decoded_samples; + result.format = decoded.format; + result.result |= decoded.result; + result.internal_result = decoded.internal_result; + m_sample_bytes = decoded.format.channel_num * AjmBytesPerSample(encoding); + m_pcm.insert(m_pcm.end(), m_frame.begin(), + m_frame.begin() + decoded.output_written); + stop_decoding = + !multiple_frames || decoded.result != OK || decoded.input_consumed == 0; + if (gapless != nullptr && gapless->IsEnd() && + (flags & AJM_INSTANCE_FLAG_GAPLESS_LOOP) != 0) { + gapless->Reset(); + stop_decoding |= (flags & AJM_INSTANCE_FLAG_SEAMLESS_LOOP) == 0; + } + continue; + } + if (!m_end || m_position >= Samples()) { + if (!m_end && !stop_decoding && (gapless == nullptr || !gapless->IsEnd())) { + result.result |= AJM_RESULT_PARTIAL_INPUT; + } + break; + } + } + + const auto current = static_cast(m_position); + const auto next = std::min(current + 1, Samples() - 1); + auto* dst = static_cast(output) + result.output_written; + const auto* a = m_pcm.data() + current * sample_bytes; + const auto* b = m_pcm.data() + next * sample_bytes; + const auto weight = m_position - static_cast(current); + if (weight == 0) { + std::memcpy(dst, a, sample_bytes); + } else { + switch (encoding) { + case AjmSampleEncoding::S16: + Interpolate(dst, a, b, format.channel_num, weight); + break; + case AjmSampleEncoding::S32: + Interpolate(dst, a, b, format.channel_num, weight); + break; + case AjmSampleEncoding::Float: + Interpolate(dst, a, b, format.channel_num, weight); + break; + } + } + result.output_written += sample_bytes; + m_position += m_ratio; + m_ratio = std::clamp(m_ratio + m_change, 0.0, 4.0); + if (m_ratio == 0 || m_ratio == 4) { + m_change = 0; + } + } + Compact(); + result.resample_info = GetInfo(); + return result; + } + +private: + template + static void Interpolate(uint8_t* dst, const uint8_t* a, const uint8_t* b, uint32_t channels, + double weight) { + for (uint32_t channel = 0; channel < channels; ++channel) { + T first, second; + std::memcpy(&first, a + channel * sizeof(T), sizeof(T)); + std::memcpy(&second, b + channel * sizeof(T), sizeof(T)); + const auto value = + static_cast(first + (static_cast(second) - first) * weight); + std::memcpy(dst + channel * sizeof(T), &value, sizeof(T)); + } + } + + [[nodiscard]] size_t Samples() const { + return m_sample_bytes == 0 ? 0 : m_pcm.size() / m_sample_bytes; + } + + void Compact() { + const auto consumed = std::min(static_cast(m_position), Samples()); + const auto bytes = consumed * m_sample_bytes; + if (bytes != 0) { + std::memmove(m_pcm.data(), m_pcm.data() + bytes, m_pcm.size() - bytes); + m_pcm.resize(m_pcm.size() - bytes); + m_position -= static_cast(consumed); + } + } + + std::vector m_pcm; + std::vector m_frame; + size_t m_sample_bytes = 0; + double m_position = 0; + double m_ratio = 1; + double m_change = 0; + bool m_end = false; +}; + +} // namespace Libs::Audio::Ajm diff --git a/tests/AjmResampleTests.cpp b/tests/AjmResampleTests.cpp new file mode 100644 index 000000000..e0a6b058b --- /dev/null +++ b/tests/AjmResampleTests.cpp @@ -0,0 +1,360 @@ +#include "libs/ajm.cpp" + +#include +#include +#include +#include +#include + +using namespace Libs::Audio::Ajm; + +namespace { + +void Check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "AjmResampleTests: %s\n", message); + std::abort(); + } +} + +struct DecodeResult { + AjmSidebandResult result; + AjmSidebandStream stream; + AjmSidebandMFrame frames; +}; + +struct Fixture { + uint32_t instance = 0; + uint32_t channels; + std::array batch_data {}; + AjmBatchInfo batch {}; + + explicit Fixture(uint32_t channels = 1, AjmSampleEncoding encoding = AjmSampleEncoding::S16, + uint64_t flags = 0x1000) + : channels(channels) { + Check(AjmInstanceCreate(1, 24, flags | channels | (uint64_t(encoding) << 7), &instance) == + OK, + "create Opus instance"); + Initialize(); + } + ~Fixture() { AjmInstanceDestroy(1, instance); } + + AjmBatchInfo* Batch() { + batch = {batch_data.data(), 0, batch_data.size(), nullptr, nullptr}; + return &batch; + } + + void Initialize() { + AjmDecOpusInitializeParameters params {channels, 48000, 0}; + AjmSidebandResult result {}; + AjmBatchJobInitialize(Batch(), instance, ¶ms, sizeof(params), &result); + Check(result.result == OK, "initialize Opus instance"); + } + + void Parameters(float ratio, uint32_t flags, float change = 0, bool extended = false) { + AjmSidebandResult result {}; + if (extended) { + AjmBatchJobSetResampleParametersEx(Batch(), instance, ratio, change, flags, &result); + } else { + AjmBatchJobSetResampleParameters(Batch(), instance, ratio, flags, &result); + } + Check(result.result == OK, "set resampling parameters"); + } + + AjmSidebandResampleInfo Info() { + struct { + AjmSidebandResult result; + AjmSidebandResampleInfo info; + } result {}; + AjmBatchJobGetResampleInfo(Batch(), instance, &result); + Check(result.result.result == OK, "get resampling info"); + return result.info; + } + + void Gapless(uint32_t samples, uint16_t skip) { + AjmSidebandGaplessDecode params {samples, skip, 0}; + AjmSidebandResult result {}; + AjmBatchJobSetGaplessDecode(Batch(), instance, ¶ms, 1, &result); + Check(result.result == OK, "set gapless parameters"); + } + + DecodeResult Decode(const void* input, size_t size, void* output, size_t capacity) { + DecodeResult result {}; + AjmBatchJobDecode(Batch(), instance, input, size, output, capacity, &result); + Check((result.result.result & ~(AJM_RESULT_NOT_ENOUGH_ROOM | AJM_RESULT_PARTIAL_INPUT)) == + 0, + "decode returned unexpected error"); + return result; + } +}; + +std::vector Encode(uint32_t channels, int packets) { + int error = 0; + auto* encoder = opus_encoder_create(48000, channels, OPUS_APPLICATION_AUDIO, &error); + Check(encoder != nullptr && error == OPUS_OK, "create test encoder"); + std::vector stream; + std::vector pcm(960 * channels); + std::array packet; + for (int p = 0; p < packets; ++p) { + for (size_t i = 0; i < pcm.size(); ++i) { + pcm[i] = static_cast( + 12000 * std::sin((i / channels + p * 960) * 0.041 + (i % channels))); + } + const int size = opus_encode(encoder, pcm.data(), 960, packet.data(), packet.size()); + Check(size > 0, "encode test packet"); + stream.push_back(static_cast(size)); + stream.push_back(static_cast(size >> 8)); + stream.insert(stream.end(), packet.begin(), packet.begin() + size); + } + opus_encoder_destroy(encoder); + return stream; +} + +void SmallOutputs(const std::vector& packet, uint16_t skip = 0) { + Fixture f; + f.Gapless(960 - skip, skip); + f.Parameters(1, 3); + std::array output; + output.fill(0x5a); + auto first = f.Decode(packet.data(), packet.size(), output.data(), 1024); + Check(first.stream.size_consumed == static_cast(packet.size()), + "small output must consume packet"); + Check(first.stream.size_produced == 1024, "small output must produce 1024 bytes"); + Check(first.frames.num_frames == 1 && first.stream.total_decoded_samples == 960 - skip, + "decoded frame counted once before resampling"); + Check(f.Info().num_samples == 448 - skip, "report retained source samples"); + Check(std::all_of(output.begin() + 1024, output.end(), [](auto v) { return v == 0x5a; }), + "do not write past output capacity"); + const auto last = f.Decode(nullptr, 0, output.data(), 1024); + Check(last.result.result == OK && last.stream.size_consumed == 0 && + last.stream.size_produced == 896 - skip * 2 && last.frames.num_frames == 0 && + last.stream.total_decoded_samples == first.stream.total_decoded_samples, + "END drains retained PCM without decoding or reporting partial input"); + Check(f.Decode(nullptr, 0, output.data(), 1024).stream.size_produced == 0, + "END drains only once"); +} + +void UnityMatchesWholeDecode(uint32_t channels, AjmSampleEncoding encoding) { + const auto stream = Encode(channels, 6); + const auto sample_bytes = channels * AjmBytesPerSample(encoding); + Fixture reference(channels, encoding, 0); + reference.Gapless(5328, 312); + std::vector expected(5760 * sample_bytes); + const auto full = + reference.Decode(stream.data(), stream.size(), expected.data(), expected.size()); + expected.resize(full.stream.size_produced); + Check(expected.size() == 5328 * sample_bytes, "reference gapless length"); + + Fixture f(channels, encoding); + f.Gapless(5328, 312); + f.Parameters(1, 3); + std::vector actual; + std::array output; + size_t offset = 0; + uint32_t frames = 0; + for (int i = 0; i < 100; ++i) { + const auto decoded = f.Decode(offset < stream.size() ? stream.data() + offset : nullptr, + stream.size() - offset, output.data(), output.size()); + offset += decoded.stream.size_consumed; + frames += decoded.frames.num_frames; + actual.insert(actual.end(), output.begin(), output.begin() + decoded.stream.size_produced); + if (decoded.stream.size_produced == 0) { + break; + } + } + Check(actual == expected && frames == 6 && offset == stream.size(), + "chunked PCM must exactly match whole decode, without loss or duplication"); +} + +void ChunkedRates(const std::vector& stream, uint32_t samples, uint16_t skip) { + for (float ratio: {0.125f, 0.749153f, 1.334840f}) { + Fixture reference; + reference.Gapless(samples, skip); + reference.Parameters(ratio, 3); + std::vector expected( + (static_cast(std::ceil(samples / double(ratio))) + 1) * 2); + const auto full = + reference.Decode(stream.data(), stream.size(), expected.data(), expected.size()); + expected.resize(full.stream.size_produced); + Check(expected.size() == static_cast(std::ceil(samples / double(ratio))) * 2, + "fractional resampling rounds up final output sample count"); + + Fixture f; + f.Gapless(samples, skip); + f.Parameters(ratio, 1); + std::vector actual; + std::array output; + size_t offset = 0; + for (int i = 0; i < 1000; ++i) { + if (offset == stream.size()) { + // The caller can signal END only after submitting the last packet. + f.Parameters(0, 2 | 8); + } + const auto decoded = f.Decode(offset < stream.size() ? stream.data() + offset : nullptr, + stream.size() - offset, output.data(), output.size()); + offset += decoded.stream.size_consumed; + actual.insert(actual.end(), output.begin(), + output.begin() + decoded.stream.size_produced); + if (decoded.stream.size_produced == 0) { + break; + } + } + Check(actual == expected && offset == stream.size(), + "non-unity PCM is independent of output chunk boundaries and deferred END"); + } +} + +void LoopBoundaries() { + const auto loop = Encode(1, 2); + auto stream = loop; + stream.insert(stream.end(), loop.begin(), loop.end()); + for (uint64_t flags: {0x1400u, 0x3400u}) { + Fixture f(1, AjmSampleEncoding::S16, flags); + f.Gapless(1500, 100); + f.Parameters(1, 1); + std::array pcm; + const auto first = f.Decode(stream.data(), stream.size(), pcm.data(), 1024); + Check(first.stream.size_produced == 1024, "loop first partial output"); + const auto second = + f.Decode(stream.data() + first.stream.size_consumed, + stream.size() - first.stream.size_consumed, pcm.data(), pcm.size()); + const bool seamless = flags == 0x3400; + Check(second.stream.size_produced == (seamless ? 6000 - 1024 : 3000 - 1024), + "non-seamless loop stops job at stream boundary"); + const auto consumed = first.stream.size_consumed + second.stream.size_consumed; + Check(consumed == static_cast(seamless ? stream.size() : loop.size()), + "loop boundary input consumption"); + if (!seamless) { + Check( + f.Decode(stream.data() + consumed, stream.size() - consumed, pcm.data(), pcm.size()) + .stream.size_produced == 3000, + "next job resumes gapless loop"); + } + } +} + +void RatesAndControls(const std::vector& packet) { + for (float ratio: {0.125f, 0.5f, 1.0f, 1.25f, 2.0f, 4.0f}) { + Fixture f; + f.Parameters(ratio, 3); + std::vector pcm(8000); + const auto result = f.Decode(packet.data(), packet.size(), pcm.data(), pcm.size() * 2); + Check(result.stream.size_produced == static_cast(std::ceil(960.0 / ratio)) * 2, + "resampling ratio controls output duration"); + Check(result.stream.total_decoded_samples == 960, + "ratio does not change decoded sample count"); + } + Fixture f; + f.Parameters(1, 1); + std::array pcm; + f.Decode(packet.data(), packet.size(), pcm.data(), sizeof(pcm)); + f.Initialize(); + Check(f.Info().num_samples == 448, "codec initialize must preserve resampler PCM"); + f.Parameters(0, 0); + const auto frozen = f.Decode(nullptr, 0, pcm.data(), sizeof(pcm)); + Check( + frozen.stream.size_produced == sizeof(pcm) && f.Info().num_samples == 448 && + std::all_of(pcm.begin(), pcm.end(), [&](auto sample) { return sample == pcm.front(); }), + "zero ratio repeats one sample without advancing input"); + f.Parameters(1, 0, 0.25f, true); + f.Parameters(std::numeric_limits::quiet_NaN(), 8); + f.Decode(nullptr, 0, pcm.data(), 4 * sizeof(int16_t)); + Check(f.Info().ratio == 2.0f, "ignore preserves ratio ramp"); + f.Parameters(1, 1); + Check(f.Info().num_samples == 0, "resample RESET discards retained PCM"); + Check(f.Decode(nullptr, 0, pcm.data(), sizeof(pcm)).stream.size_produced == 0, + "RESET leaves no stale audio"); +} + +void UnbufferedAndSplit(const std::vector& packet) { + Fixture raw(1, AjmSampleEncoding::S16, 0); + std::array pcm; + const auto too_small = raw.Decode(packet.data(), packet.size(), pcm.data(), pcm.size()); + Check(too_small.result.result == AJM_RESULT_NOT_ENOUGH_ROOM && + too_small.stream.size_consumed == 0 && too_small.stream.size_produced == 0, + "ordinary decoding still requires room for a whole packet"); + Fixture f; + f.Parameters(1, 3); + AjmBuffer input[] {{const_cast(packet.data()), 1}, + {const_cast(packet.data()) + 1, packet.size() - 1}}; + AjmBuffer output[] {{pcm.data(), 257}, {pcm.data() + 257, 767}}; + DecodeResult result {}; + AjmBatchJobDecodeSplit(f.Batch(), f.instance, input, 2, output, 2, &result); + Check(result.stream.size_produced == 1024 && + result.stream.size_consumed == static_cast(packet.size()), + "split buffers retain PCM across jobs"); +} + +void FormatChangeRequiresReset() { + Fixture f(2); + f.Parameters(1, 1); + const auto stereo = Encode(2, 1); + std::array pcm; + f.Decode(stereo.data(), stereo.size(), pcm.data(), 1024); + f.channels = 1; + f.Initialize(); + DecodeResult result {}; + AjmBatchJobDecode(f.Batch(), f.instance, nullptr, 0, pcm.data(), pcm.size(), &result); + Check(result.result.result == AJM_RESULT_INVALID_PARAMETER && result.stream.size_produced == 0, + "format change cannot reinterpret retained PCM with a different channel stride"); + f.Parameters(1, 3); + const auto mono = Encode(1, 1); + Check(f.Decode(mono.data(), mono.size(), pcm.data(), pcm.size()).stream.size_produced == 1920, + "resampler RESET permits a new stream format"); +} + +void EndPersistsUntilReset(const std::vector& packet) { + Fixture f; + f.Parameters(0.5f, 1); + std::array pcm; + auto decoded = f.Decode(packet.data(), packet.size(), pcm.data(), sizeof(pcm)); + size_t produced = decoded.stream.size_produced; + f.Parameters(0.5f, 2); + for (int i = 0; i < 40; ++i) { + f.Parameters(0.5f, 0); + decoded = f.Decode(nullptr, 0, pcm.data(), sizeof(pcm)); + produced += decoded.stream.size_produced; + Check((decoded.result.result & AJM_RESULT_PARTIAL_INPUT) == 0, + "END remains active across subsequent ratio controls"); + if (f.Info().num_samples < 0) { + break; + } + } + Check(produced == 3840 && f.Info().num_samples < 0, + "fully drained END reports a negative retained sample count"); + f.Parameters(1, 1); + Check(f.Info().num_samples == 0, "RESET clears end-of-stream state"); +} + +} // namespace + +int main(int argc, char** argv) { + const auto packet = Encode(1, 1); + SmallOutputs(packet); + SmallOutputs(packet, 312); + for (uint32_t channels: {1u, 2u}) { + for (auto encoding: + {AjmSampleEncoding::S16, AjmSampleEncoding::S32, AjmSampleEncoding::Float}) { + UnityMatchesWholeDecode(channels, encoding); + } + } + RatesAndControls(packet); + UnbufferedAndSplit(packet); + ChunkedRates(Encode(1, 6), 5328, 312); + LoopBoundaries(); + FormatChangeRequiresReset(); + EndPersistsUntilReset(packet); + if (argc >= 2) { + std::ifstream file(argv[1], std::ios::binary); + std::vector real_packet((std::istreambuf_iterator(file)), {}); + Check(real_packet.size() == 209, "expected real 209-byte packet fixture"); + SmallOutputs(real_packet, 312); + } + if (argc >= 3) { + std::ifstream file(argv[2], std::ios::binary); + std::vector real_stream((std::istreambuf_iterator(file)), {}); + Check(real_stream.size() == 4048, "expected complete real stream fixture"); + ChunkedRates(real_stream, 25920, 312); + } + std::puts("AjmResampleTests: passed"); +}