From 697f130b6635b07fb129a616d2d0bbe3d70dc3fc Mon Sep 17 00:00:00 2001 From: TheM14 Date: Mon, 28 Sep 2026 19:01:05 +0800 Subject: [PATCH] Add stream HRTF inputs and drop unused renderer gain --- include/eac3joc_core.h | 6 +- include/joc_core.h | 6 +- include/joc_stream.h | 16 ++++ src/api/joc_stream_api.cpp | 10 +++ src/stream/stream.cpp | 169 ++++++++++++++++++++++++++++++++----- src/stream/stream.h | 28 +++++- src/task/task.cpp | 1 - 7 files changed, 209 insertions(+), 27 deletions(-) diff --git a/include/eac3joc_core.h b/include/eac3joc_core.h index a227d15..6cddf54 100644 --- a/include/eac3joc_core.h +++ b/include/eac3joc_core.h @@ -2,7 +2,11 @@ #include -#if defined(_WIN32) +/* EJOC_STATIC: this branch exists for building the sources directly into an application, where nothing is imported or exported. */ +#if defined(EJOC_STATIC) + #define EJOC_API + #define EJOC_CALL __cdecl +#elif defined(_WIN32) #if defined(EJOC_BUILD_DLL) #define EJOC_API __declspec(dllexport) #else diff --git a/include/joc_core.h b/include/joc_core.h index 3ad47ed..bff1ae8 100644 --- a/include/joc_core.h +++ b/include/joc_core.h @@ -41,7 +41,11 @@ #define JOC_TASK_RESULT_VERSION 2u #define JOC_EVENT_VERSION 1u -#if defined(_WIN32) +/* JOC_STATIC: this branch exists for building the sources directly into an application, where nothing is imported or exported. */ +#if defined(JOC_STATIC) + #define JOC_API + #define JOC_CALL __cdecl +#elif defined(_WIN32) #if defined(JOC_BUILD_DLL) #define JOC_API __declspec(dllexport) #else diff --git a/include/joc_stream.h b/include/joc_stream.h index 755e984..4e04962 100644 --- a/include/joc_stream.h +++ b/include/joc_stream.h @@ -25,6 +25,12 @@ #include "joc_core.h" +/* JOC_STATIC (building the sources directly into an application): joc_core.h above already installs the empty JOC_API. */ +#if defined(JOC_STATIC) && !defined(JOC_API) + #define JOC_API + #define JOC_CALL __cdecl +#endif + #ifdef __cplusplus extern "C" { #endif @@ -58,6 +64,16 @@ typedef struct joc_stream_config { double gain_db; /* default 0 */ uint32_t native_threads; uint32_t reserved; + /* Binaural HRTF input, the same three shapes joc_task_config accepts: when + * hrtf_sofa_path is set the library compiles it with hrtf_cache_policy / + * hrtf_cache_dir / hrtf_radius_m and hrtf_path is unused; when + * personalized_headphone_path is set the Rosella runtime renders instead. + * hrtf_path stays the fallback/advanced input that reads a .jochrtf directly. */ + const char* hrtf_sofa_path; /* SOFA SimpleFreeFieldHRIR input */ + const char* personalized_headphone_path; /* Rosella .personalized_headphone input */ + const char* hrtf_cache_dir; /* disk cache directory for SOFA compilation */ + uint32_t hrtf_cache_policy; /* joc_hrtf_cache_policy: 0 none, 1 memory, 2 disk */ + double hrtf_radius_m; /* SOFA measurement-radius shell, default 1.0 */ } joc_stream_config; typedef struct joc_stream_buffer { diff --git a/src/api/joc_stream_api.cpp b/src/api/joc_stream_api.cpp index 177838c..a48c5cc 100644 --- a/src/api/joc_stream_api.cpp +++ b/src/api/joc_stream_api.cpp @@ -33,6 +33,16 @@ joc::stream::Config to_config(const joc_stream_config& config) { } out.gain_db = config.gain_db; out.native_threads = config.native_threads; + // The binaural HRTF inputs of joc_task_config, copied with the same defaults: + // the policy is taken verbatim (0 is "none", a real choice, not "unset") and + // the radius keeps its documented default of 1.0 when the field is not set. + if (config.hrtf_sofa_path != nullptr) { out.hrtf_sofa_path = config.hrtf_sofa_path; } + if (config.personalized_headphone_path != nullptr) { + out.personalized_headphone_path = config.personalized_headphone_path; + } + if (config.hrtf_cache_dir != nullptr) { out.hrtf_cache_dir = config.hrtf_cache_dir; } + out.hrtf_cache_policy = config.hrtf_cache_policy; + if (config.hrtf_radius_m > 0.0) { out.hrtf_radius_m = config.hrtf_radius_m; } return out; } diff --git a/src/stream/stream.cpp b/src/stream/stream.cpp index 37f8c79..f6b7fd8 100644 --- a/src/stream/stream.cpp +++ b/src/stream/stream.cpp @@ -7,6 +7,8 @@ #include "adm/adm_metadata.h" #include "foundation/status.h" #include "hrtf/jochrtf.h" +#include "hrtf/rosella_model.h" +#include "hrtf/sofa_cache.h" #include "joc_bitstream/joc_parser.h" #include "joc_core/objects16.h" @@ -53,6 +55,11 @@ void Stream::reset_state() { if (binaural_ready_) { binaural_.reset(); } + if (rosella_ready_) { + rosella_.reset(); + } + rosella_pending_.clear(); + rosella_read_offset_ = 0; } Status Stream::create(const Config& config) { @@ -96,28 +103,74 @@ Status Stream::create(const Config& config) { output_channels_ = speaker_.layout.channel_count; } else if (config_.output == JOC_STREAM_OUT_BINAURAL) { binaural_enabled_ = true; - hrtf::Field field; - hrtf::Kernels kernels; - Status status = hrtf::load_jochrtf(config_.hrtf_path, &field); - if (status.ok()) { - status = config_.kernels_path.empty() - ? (kernels = hrtf::builtin_kernels(), Status::success()) - : hrtf::load_kernels(config_.kernels_path, &kernels); + // The HRTF input precedence is joc_task_config's: a Rosella + // .personalized_headphone wins over a SOFA that the library compiles, and + // the compiled .jochrtf (hrtf_path) stays the fallback input. + if (!config_.personalized_headphone_path.empty()) { + hrtf::RosellaModel model; + Status status = + hrtf::load_personalized_headphone(config_.personalized_headphone_path, &model); + hrtf::RosellaRenderOptions render_options; + if (config_.binaural_mode == JOC_BINAURAL_NEAR) { + render_options.profile = hrtf::RosellaProfile::Near; + } else if (config_.binaural_mode == JOC_BINAURAL_FAR) { + render_options.profile = hrtf::RosellaProfile::Far; + } + render_options.object_delay_samples = config_.object_delay_samples; + render_options.tail_seconds = config_.tail_seconds; + if (status.ok()) { + status = rosella_.open(model, render_options); + } + if (!status.ok()) { + return Status::fail(status.code(), stage::kRender, + "binaural setup failed: " + status.message()); + } + rosella_ready_ = true; + output_channels_ = 2; + } else { + hrtf::Field field; + hrtf::Kernels kernels; + Status status = Status::success(); + if (!config_.hrtf_sofa_path.empty()) { + // The .jochrtf is an internal cache: the SOFA is the user-facing input. + hrtf::SofaFieldRequest request; + request.sofa_path = config_.hrtf_sofa_path; + request.options.shell_radius_m = config_.hrtf_radius_m; + request.cache_dir = config_.hrtf_cache_dir; + switch (config_.hrtf_cache_policy) { + case JOC_HRTF_CACHE_NONE: request.policy = hrtf::CachePolicy::None; break; + case JOC_HRTF_CACHE_DISK: request.policy = hrtf::CachePolicy::Disk; break; + default: request.policy = hrtf::CachePolicy::Memory; break; + } + std::string cache_path; + status = hrtf::load_or_compile_sofa_field(request, &field, &cache_path); + if (!status.ok()) { + return Status::fail(status.code(), stage::kRender, + "binaural setup failed: " + status.message()); + } + } else { + status = hrtf::load_jochrtf(config_.hrtf_path, &field); + } + if (status.ok()) { + status = config_.kernels_path.empty() + ? (kernels = hrtf::builtin_kernels(), Status::success()) + : hrtf::load_kernels(config_.kernels_path, &kernels); + } + binaural::Profile profile = binaural::Profile::Mid; + if (status.ok() && config_.binaural_mode == JOC_BINAURAL_NEAR) { + profile = binaural::Profile::Near; + } else if (status.ok() && config_.binaural_mode == JOC_BINAURAL_FAR) { + profile = binaural::Profile::Far; + } + if (status.ok()) { + status = binaural_.open(field, kernels, profile); + } + if (!status.ok()) { + return status; + } + binaural_ready_ = true; + output_channels_ = 2; } - binaural::Profile profile = binaural::Profile::Mid; - if (status.ok() && config_.binaural_mode == JOC_BINAURAL_NEAR) { - profile = binaural::Profile::Near; - } else if (status.ok() && config_.binaural_mode == JOC_BINAURAL_FAR) { - profile = binaural::Profile::Far; - } - if (status.ok()) { - status = binaural_.open(field, kernels, profile); - } - if (!status.ok()) { - return status; - } - binaural_ready_ = true; - output_channels_ = 2; } else { output_channels_ = JOC_OUTPUT_CHANNELS; } @@ -273,6 +326,9 @@ Status Stream::render_objects16(const std::vector& objects16) { info_.samples_out += kFrameSamples; return Status::success(); } + if (rosella_ready_) { + return render_rosella_objects16(objects16); + } const Status submitted = binaural_.submit_frame(objects16.data(), @@ -293,6 +349,55 @@ Status Stream::render_objects16(const std::vector& objects16) { return Status::success(); } +// The Rosella runtime is driven exactly like the SOFA runtime (the same frame, +// update, frame index, outer offset and object delay), but it renders in chunks +// of its own size (64 frames by default), so a frame usually yields either +// nothing or a whole chunk. Its output therefore waits in a FIFO and is released +// one frame's worth at a time, which keeps the stream's contract intact: pushing +// one syncframe leaves exactly JOC_FRAME_SAMPLES samples for the caller to pull, +// and nothing is dropped or counted twice. pull() and flush() release the rest. +Status Stream::render_rosella_objects16(const std::vector& objects16) { + const Status submitted = + rosella_.submit_frame(objects16.data(), + pending_metadata_.has_update ? &pending_metadata_.update : nullptr, + static_cast(info_.frames_out), + pending_metadata_.outer_offset, + static_cast(config_.object_delay_samples)); + if (!submitted.ok()) { + return submitted; + } + std::vector produced; + rosella_.take_output(&produced); + if (!produced.empty()) { + rosella_pending_.insert(rosella_pending_.end(), produced.begin(), produced.end()); + } + release_rosella_output(kFrameSamples); + info_.frames_out++; + // Counted as the runtime produces it, which is also how the SOFA path counts: + // the totals are identical, only the frame they appear on differs. + info_.samples_out += produced.size() / 2u; + return Status::success(); +} + +void Stream::release_rosella_output(std::size_t limit) { + if (!rosella_ready_ || limit == 0u) { + return; + } + const std::size_t count = std::min(limit, rosella_pending_samples()); + if (count == 0u) { + return; + } + const std::size_t values = count * 2u; + for (std::size_t index = 0; index < values; ++index) { + output_.push_back(static_cast(rosella_pending_[rosella_read_offset_ + index])); + } + rosella_read_offset_ += values; + if (rosella_read_offset_ == rosella_pending_.size()) { + rosella_pending_.clear(); + rosella_read_offset_ = 0; + } +} + Status Stream::pull(float* destination, std::size_t capacity_samples, std::size_t* produced) { if (produced != nullptr) { *produced = 0; @@ -300,6 +405,10 @@ Status Stream::pull(float* destination, std::size_t capacity_samples, std::size_ if (destination == nullptr || produced == nullptr) { return Status::fail(JOC_ERR_INVALID_ARGUMENT, stage::kOutput, "null pull buffer"); } + // Rendered Rosella samples that the frame-at-a-time release above has not + // handed over yet are still the caller's to take; releasing them here keeps + // buffered_samples() and the amount pull() can deliver the same number. + release_rosella_output(capacity_samples); const std::size_t available = buffered_samples(); const std::size_t count = std::min(capacity_samples, available); if (count != 0u) { @@ -332,12 +441,28 @@ Status Stream::flush() { } info_.samples_out += tail.size() / 2u; } + if (rosella_ready_) { + std::vector tail; + const Status drained = + rosella_.finish(rosella_.finish_capacity(config_.tail_seconds), &tail); + if (!drained.ok()) { + return drained; + } + // Everything the runtime produced as the program is released first: the + // tail only sounds after it. The program samples were already counted by + // render_rosella_objects16, so only the tail is added here. + release_rosella_output(rosella_pending_samples()); + for (const double value : tail) { + output_.push_back(static_cast(value)); + } + info_.samples_out += tail.size() / 2u; + } info_.ended = 1; return Status::success(); } Status Stream::reset() { - if (rebuilder_ == nullptr && !speaker_enabled_ && !binaural_ready_ && + if (rebuilder_ == nullptr && !speaker_enabled_ && !binaural_ready_ && !rosella_ready_ && config_.input != JOC_STREAM_IN_PCM_OBJECTS16) { return Status::fail(JOC_ERR_STATE, stage::kRender, "stream is not created"); } diff --git a/src/stream/stream.h b/src/stream/stream.h index b765e3e..99d61d4 100644 --- a/src/stream/stream.h +++ b/src/stream/stream.h @@ -9,6 +9,7 @@ #include "binaural/binaural_runtime.h" #include "eac3_transport/eac3_reader.h" #include "foundation/status.h" +#include "hrtf/rosella_renderer.h" #include "joc_core.h" #include "joc_stream.h" #include "oamd/oamd_parser.h" @@ -28,6 +29,13 @@ struct Config { std::uint32_t object_delay_samples = 1473; double gain_db = 0.0; std::uint32_t native_threads = 0; + // The three HRTF shapes of joc_task_config, in its precedence order: a Rosella + // model wins over a SOFA, and hrtf_path (.jochrtf) is the fallback. + std::string hrtf_sofa_path; + std::string personalized_headphone_path; + std::string hrtf_cache_dir; + std::uint32_t hrtf_cache_policy = JOC_HRTF_CACHE_MEMORY; + double hrtf_radius_m = 1.0; }; struct Info { @@ -67,14 +75,26 @@ public: Status reset(); const Info& info() const { return info_; } - // Per-channel sample count, not the interleaved float count. + // Per-channel sample count, not the interleaved float count. The Rosella + // runtime renders in its own chunk size, so its output waits in a FIFO before + // it is released one frame at a time; those samples are rendered and unpulled + // as well, so the reported backlog has to include them. std::size_t buffered_samples() const { - return output_channels_ != 0u ? (output_.size() - read_offset_) / output_channels_ : 0u; + return output_channels_ != 0u + ? (output_.size() - read_offset_) / output_channels_ + rosella_pending_samples() + : 0u; } private: Status process_ready_frames(); Status render_objects16(const std::vector& objects16); + Status render_rosella_objects16(const std::vector& objects16); + // Moves at most `limit` rendered stereo samples per channel out of the FIFO + // into output_, oldest sample first. + void release_rosella_output(std::size_t limit); + std::size_t rosella_pending_samples() const { + return rosella_ready_ ? (rosella_pending_.size() - rosella_read_offset_) / 2u : 0u; + } void reset_state(); Config config_; @@ -92,9 +112,13 @@ private: ejoc_renderer_handle rebuilder_ = nullptr; speaker::SpeakerStep speaker_; binaural::SofaBinauralRuntime binaural_; + hrtf::RosellaRuntime rosella_; + std::vector rosella_pending_; + std::size_t rosella_read_offset_ = 0; bool speaker_enabled_ = false; bool binaural_enabled_ = false; bool binaural_ready_ = false; + bool rosella_ready_ = false; float gain_ = 1.0f; }; diff --git a/src/task/task.cpp b/src/task/task.cpp index 4a4677f..040fb65 100644 --- a/src/task/task.cpp +++ b/src/task/task.cpp @@ -1209,7 +1209,6 @@ Status run(const joc_task_config& raw, const joc_event_sink* sink, joc_task_resu } render_options.object_delay_samples = cfg.object_delay_samples; render_options.tail_seconds = cfg.tail_seconds; - render_options.output_gain = 1.0; if (status.ok()) { status = rosella.open(model, render_options); }